repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
certificates
stars
latest
clone command
git clone gitlawb://did:key:z6Mkqhmm...XL9c/certificatesgit clone gitlawb://did:key:z6Mkqhmm.../certificates019974a8sync from playground16h ago| #1 | import { useEffect, useState } from "react"; |
| #2 | import { collection, getDocs, query, orderBy, limit } from "firebase/firestore"; |
| #3 | import { db } from "../firebase"; |
| #4 | import type { Certificate } from "../types"; |
| #5 | import { Award, CheckCircle, XCircle, Clock, FileImage } from "lucide-react"; |
| #6 | import { Link } from "react-router-dom"; |
| #7 | import { useToast } from "../components/Toast"; |
| #8 | |
| #9 | interface Stats { |
| #10 | total: number; |
| #11 | active: number; |
| #12 | revoked: number; |
| #13 | uploaded: number; |
| #14 | } |
| #15 | |
| #16 | export default function Dashboard() { |
| #17 | const [stats, setStats] = useState<Stats>({ total: 0, active: 0, revoked: 0 }); |
| #18 | const [recent, setRecent] = useState<Certificate[]>([]); |
| #19 | const [loading, setLoading] = useState(true); |
| #20 | const { addToast } = useToast(); |
| #21 | |
| #22 | useEffect(() => { |
| #23 | (async () => { |
| #24 | try { |
| #25 | const snap = await getDocs(collection(db, "certificates")); |
| #26 | const certs = snap.docs.map((d) => ({ id: d.id, ...d.data() } as Certificate)); |
| #27 | setStats({ |
| #28 | total: certs.length, |
| #29 | active: certs.filter((c) => c.status === "active").length, |
| #30 | revoked: certs.filter((c) => c.status === "revoked").length, |
| #31 | uploaded: certs.filter((c) => c.certType === "uploaded").length, |
| #32 | }); |
| #33 | |
| #34 | const q = query(collection(db, "certificates"), orderBy("createdAt", "desc"), limit(5)); |
| #35 | const recentSnap = await getDocs(q); |
| #36 | setRecent(recentSnap.docs.map((d) => ({ id: d.id, ...d.data() } as Certificate))); |
| #37 | } catch (err: unknown) { |
| #38 | const msg = err instanceof Error ? err.message : String(err); |
| #39 | if (msg.includes("permission-denied")) { |
| #40 | addToast("Access denied — check Firestore security rules.", "error"); |
| #41 | } else { |
| #42 | addToast("Failed to load dashboard data.", "error"); |
| #43 | } |
| #44 | } finally { |
| #45 | setLoading(false); |
| #46 | } |
| #47 | })(); |
| #48 | }, []); |
| #49 | |
| #50 | if (loading) { |
| #51 | return ( |
| #52 | <div className="flex items-center justify-center py-20"> |
| #53 | <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-emerald-600" /> |
| #54 | </div> |
| #55 | ); |
| #56 | } |
| #57 | |
| #58 | return ( |
| #59 | <div> |
| #60 | <h1 className="text-2xl font-bold text-gray-900 mb-6">Dashboard</h1> |
| #61 | |
| #62 | {/* Stats */} |
| #63 | <div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8"> |
| #64 | {[ |
| #65 | { label: "Total Issued", value: stats.total, icon: Award, color: "blue" }, |
| #66 | { label: "Active", value: stats.active, icon: CheckCircle, color: "emerald" }, |
| #67 | { label: "Revoked", value: stats.revoked, icon: XCircle, color: "red" }, |
| #68 | { label: "Uploaded", value: stats.uploaded, icon: FileImage, color: "purple" }, |
| #69 | ].map(({ label, value, icon: Icon, color }) => ( |
| #70 | <div key={label} className="bg-white rounded-xl border border-gray-200 p-6"> |
| #71 | <div className="flex items-center gap-3"> |
| #72 | <div className={`w-10 h-10 rounded-lg flex items-center justify-center bg-${color}-50`}> |
| #73 | <Icon className={`w-5 h-5 text-${color}-600`} /> |
| #74 | </div> |
| #75 | <div> |
| #76 | <p className="text-2xl font-bold text-gray-900">{value}</p> |
| #77 | <p className="text-sm text-gray-500">{label}</p> |
| #78 | </div> |
| #79 | </div> |
| #80 | </div> |
| #81 | ))} |
| #82 | </div> |
| #83 | |
| #84 | {/* Recent certs */} |
| #85 | <div className="bg-white rounded-xl border border-gray-200 overflow-hidden"> |
| #86 | <div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between"> |
| #87 | <h2 className="font-semibold text-gray-900 flex items-center gap-2"> |
| #88 | <Clock className="w-4 h-4 text-gray-400" /> Recent Certificates |
| #89 | </h2> |
| #90 | <Link to="/admin/certs" className="text-sm text-emerald-600 hover:text-emerald-700 font-medium"> |
| #91 | View all → |
| #92 | </Link> |
| #93 | </div> |
| #94 | {recent.length === 0 ? ( |
| #95 | <div className="px-6 py-12 text-center text-gray-400 text-sm"> |
| #96 | No certificates issued yet. |
| #97 | </div> |
| #98 | ) : ( |
| #99 | <table className="w-full text-sm"> |
| #100 | <thead> |
| #101 | <tr className="text-left text-gray-500 border-b border-gray-100"> |
| #102 | <th className="px-6 py-3 font-medium">Recipient</th> |
| #103 | <th className="px-6 py-3 font-medium">Course</th> |
| #104 | <th className="px-6 py-3 font-medium">Date</th> |
| #105 | <th className="px-6 py-3 font-medium">Status</th> |
| #106 | </tr> |
| #107 | </thead> |
| #108 | <tbody> |
| #109 | {recent.map((c) => ( |
| #110 | <tr key={c.id} className="border-b border-gray-50 last:border-0"> |
| #111 | <td className="px-6 py-3 font-medium text-gray-900">{c.recipientName}</td> |
| #112 | <td className="px-6 py-3 text-gray-600">{c.courseTitle}</td> |
| #113 | <td className="px-6 py-3 text-gray-500"> |
| #114 | {new Date(c.issueDate).toLocaleDateString()} |
| #115 | </td> |
| #116 | <td className="px-6 py-3"> |
| #117 | <span |
| #118 | className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${ |
| #119 | c.status === "active" |
| #120 | ? "bg-emerald-50 text-emerald-700" |
| #121 | : "bg-red-50 text-red-700" |
| #122 | }`} |
| #123 | > |
| #124 | {c.status === "active" ? ( |
| #125 | <CheckCircle className="w-3 h-3" /> |
| #126 | ) : ( |
| #127 | <XCircle className="w-3 h-3" /> |
| #128 | )} |
| #129 | {c.status} |
| #130 | </span> |
| #131 | </td> |
| #132 | </tr> |
| #133 | ))} |
| #134 | </tbody> |
| #135 | </table> |
| #136 | )} |
| #137 | </div> |
| #138 | </div> |
| #139 | ); |
| #140 | } |
| #141 |