Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# backend/Dockerfile

# 1. Use a lightweight Node image
FROM node:18-alpine

# 2. Set workdir
WORKDIR /app

# 3. Copy dependency manifests & install
COPY package*.json ./
RUN npm ci

# 4. Copy source code
COPY . .

# 5. Set environment variables
ENV NODE_ENV=development
ENV PORT=4000

# 6. Expose the port your API listens on
EXPOSE 4000

# 7. Start the app
CMD ["npm", "run", "dev"]
308 changes: 308 additions & 0 deletions frontend/src/components/DataTable/AdminList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,308 @@
import {
ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
SortingState,
useReactTable,
Row,
VisibilityState,
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import request from "@/lib/request";
import TableSkeleton from "../ui/TableSkeleton";
import TableError from "../ui/TableError";
import { Button } from "../ui/button";
import { Trash, MoreVertical, ChevronDown } from "lucide-react";
import { toast } from "sonner";
import ShowIfPermission from "../Auth/ShowIfPermission";
import { useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuCheckboxItem,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import TableHeading from "./TableHeading";
import Resizer from "./Resizer";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

// Admin data interface
interface Admin {
teacher_id: string;
first_name: string;
last_name: string;
email: string;
}

// Table columns configuration
const columns: ColumnDef<Admin>[] = [
{
id: "actions",
cell: ActionsDropdown,
size: 50,
},
{
accessorKey: "first_name",
header: ({ column }) => <TableHeading column={column} label="First Name" />,
size: 160,
maxSize: 250,
minSize: 50,
meta: { heading: "First Name" },
},
{
accessorKey: "last_name",
header: ({ column }) => <TableHeading column={column} label="Last Name" />,
size: 160,
maxSize: 250,
minSize: 50,
meta: { heading: "Last Name" },
},
{
accessorKey: "email",
header: ({ column }) => <TableHeading column={column} label="Email" />,
size: 200,
maxSize: 300,
minSize: 100,
meta: { heading: "Email" },
},
];

// Main admin list component
export function AdminList() {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});

// Fetch admins data
const query = useQuery({
queryKey: ["admins"],
queryFn: async () => {
const response = await request.get("/teachers/admins-only");
return response.data;
},
});

// Initialize table with data
const table = useReactTable({
data: query.data || [],
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
onSortingChange: setSorting,
onColumnVisibilityChange: setColumnVisibility,
state: {
sorting,
columnVisibility,
},
});

return (
<div>
<div className="w-full flex flex-row items-end mt-8 mb-6">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="ml-auto w-[250px] justify-between"
>
Columns
<ChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom" collisionPadding={16}>
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize whitespace-nowrap pr-10"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
onSelect={(event) => event.preventDefault()}
>
<span>{column.columnDef.meta?.heading}</span>
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>

<div className="rounded-md">
<Table className="max-w-none w-max border">
{/* Table header */}
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
style={{ width: `${header.getSize()}px` }}
className="relative font-bold text-slate-900"
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
<Resizer table={table} header={header} />
</TableHead>
))}
</TableRow>
))}
</TableHeader>

{/* Table body */}
<TableBody>
{query.isLoading ? (
<TableSkeleton table={table} />
) : query.isError ? (
<TableError colSpan={columns.length} onRetry={query.refetch} />
) : table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row, i) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className={i % 2 === 0 ? "bg-white" : "bg-slate-100"}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{ width: `${cell.column.getSize()}px` }}
className="font-normal"
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No admins found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
}

// Actions dropdown menu component
function ActionsDropdown({ row }: { row: Row<Admin> }) {
const admin = row.original;
const queryClient = useQueryClient();
const [showPasswordDialog, setShowPasswordDialog] = useState(false);
const [password, setPassword] = useState("");

const deleteMutation = useMutation({
mutationFn: async () => {
const response = await request.delete(`/teachers/${admin.teacher_id}`, {
data: { password }
});
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admins"] });
toast.success("Admin deleted successfully");
setShowPasswordDialog(false);
setPassword("");
},
onError: (error) => {
toast.error(error.message || "Failed to delete admin");
setPassword("");
},
});

const handleDelete = () => {
setShowPasswordDialog(true);
};

const handleConfirmDelete = () => {
if (password.trim()) {
deleteMutation.mutate();
} else {
toast.error("Please enter the password");
}
};

return (
<ShowIfPermission permission="delete:teacher">
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className={cn({ "hover:bg-slate-200": row.index % 2 === 1 })}
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-red-600"
onClick={handleDelete}
>
<Trash className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

<Dialog open={showPasswordDialog} onOpenChange={setShowPasswordDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm Admin Deletion</DialogTitle>
</DialogHeader>
<div className="py-4">
<Label htmlFor="password">Enter your password to confirm deletion</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
className="mt-2"
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowPasswordDialog(false)}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleConfirmDelete}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</ShowIfPermission>
);
}
Loading