feat: implementasi Kodeva - sistem manajemen kursus coding

Fitur lengkap:
- Auth JWT + RBAC (Admin/Teacher) + halaman login
- Shell 3 kolom (left nav, topbar, right rail dashboard)
- CRUD siswa + follow up kanban + status calon/student/ex
- Dashboard: siswa aktif, kehadiran, bar sumber lead, perlu follow up
- Pembayaran prorata per pertemuan (cash/transfer/qris)
- Kelas, enrollment, generate sesi, kehadiran bulk
- Jurnal aktivitas + upload multi-foto
- Raport + placement test (tampil di detail siswa)
- Pengaturan & seed data contoh

Stack: Next.js 16, TypeScript, Tailwind v4, Prisma (SQLite), Recharts, Zod
This commit is contained in:
Amadea
2026-09-18 19:24:59 +07:00
commit 61ce9d7926
86 changed files with 13392 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
import { cn, initials } from "@/lib/utils";
export function Avatar({
src,
name,
size = 40,
className,
ring,
}: {
src?: string | null;
name: string;
size?: number;
className?: string;
ring?: boolean;
}) {
const style = { width: size, height: size };
return (
<span
className={cn(
"relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-brand-100 text-[13px] font-semibold text-brand-700",
ring && "ring-2 ring-brand-500 ring-offset-2 ring-offset-surface",
className,
)}
style={style}
>
{src ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={src} alt={name} className="h-full w-full object-cover" />
) : (
initials(name)
)}
</span>
);
}
+41
View File
@@ -0,0 +1,41 @@
import type { HTMLAttributes } from "react";
import { cn } from "@/lib/utils";
type BadgeProps = HTMLAttributes<HTMLSpanElement> & {
bg?: string;
fg?: string;
dot?: string;
};
export function Badge({ className, bg, fg, dot, children, style, ...props }: BadgeProps) {
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[12px] font-semibold",
!bg && "bg-ink-100 text-ink-700",
className,
)}
style={{ backgroundColor: bg, color: fg, ...style }}
{...props}
>
{dot ? <span className="size-1.5 rounded-full" style={{ backgroundColor: dot }} /> : null}
{children}
</span>
);
}
export function Chip({ className, color, children, ...props }: HTMLAttributes<HTMLSpanElement> & { color?: string }) {
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[12px] font-medium text-ink-700",
className,
)}
style={color ? { borderColor: `${color}55`, backgroundColor: `${color}14` } : undefined}
{...props}
>
{color ? <span className="size-1.5 rounded-full" style={{ backgroundColor: color }} /> : null}
{children}
</span>
);
}
+34
View File
@@ -0,0 +1,34 @@
import type { ButtonHTMLAttributes } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 font-medium transition-all duration-150 disabled:pointer-events-none disabled:opacity-50 whitespace-nowrap",
{
variants: {
variant: {
primary: "grad-primary text-white shadow-[var(--shadow-brand)] hover:-translate-y-px hover:brightness-105",
secondary: "bg-brand-50 text-brand-700 hover:bg-brand-100",
outline: "border border-ink-300 bg-surface text-ink-900 hover:bg-ink-100",
ghost: "text-ink-700 hover:bg-ink-100",
danger: "bg-danger-500 text-white hover:brightness-105",
coral: "grad-coral text-white hover:-translate-y-px",
},
size: {
sm: "h-9 rounded-[10px] px-3 text-[13px]",
md: "h-10 rounded-[12px] px-4 text-sm",
lg: "h-12 rounded-[14px] px-5 text-[15px]",
icon: "h-10 w-10 rounded-[12px]",
},
},
defaultVariants: { variant: "primary", size: "md" },
},
);
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export function Button({ className, variant, size, ...props }: ButtonProps) {
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
}
+31
View File
@@ -0,0 +1,31 @@
import type { HTMLAttributes } from "react";
import { cn } from "@/lib/utils";
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("rounded-[20px] border border-ink-100 bg-surface shadow-[var(--shadow-sm)]", className)}
{...props}
/>
);
}
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex items-center justify-between gap-3 px-5 pt-5 pb-3", className)} {...props} />;
}
export function CardTitle({ className, ...props }: HTMLAttributes<HTMLHeadingElement>) {
return <h3 className={cn("text-[15px] font-semibold text-ink-900", className)} {...props} />;
}
export function CardDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn("text-[13px] text-ink-500", className)} {...props} />;
}
export function CardBody({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("px-5 pb-5", className)} {...props} />;
}
export function CardFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex items-center gap-2 border-t border-ink-100 px-5 py-4", className)} {...props} />;
}
+28
View File
@@ -0,0 +1,28 @@
import type { ReactNode } from "react";
export function EmptyState({
icon,
title,
description,
action,
}: {
icon?: ReactNode;
title: string;
description?: string;
action?: ReactNode;
}) {
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-[20px] border border-dashed border-ink-300 bg-surface px-6 py-12 text-center">
{icon ? (
<span className="grad-primary flex size-14 items-center justify-center rounded-2xl text-white opacity-90">
{icon}
</span>
) : null}
<div>
<h3 className="text-[16px] font-semibold text-ink-900">{title}</h3>
{description ? <p className="mt-1 max-w-sm text-[13px] text-ink-500">{description}</p> : null}
</div>
{action}
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
import type { InputHTMLAttributes, LabelHTMLAttributes, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
import { cn } from "@/lib/utils";
export function Label({ className, ...props }: LabelHTMLAttributes<HTMLLabelElement>) {
return <label className={cn("mb-1.5 block text-[13px] font-medium text-ink-700", className)} {...props} />;
}
const baseField =
"w-full rounded-[12px] border border-ink-300 bg-surface px-3 text-sm text-ink-900 placeholder:text-ink-500 transition-colors focus:border-brand-500 focus:outline-none focus:ring-[3px] focus:ring-brand-500/25 disabled:bg-ink-100";
export function Input({ className, ...props }: InputHTMLAttributes<HTMLInputElement>) {
return <input className={cn(baseField, "h-10", className)} {...props} />;
}
export function Textarea({ className, ...props }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
return <textarea className={cn(baseField, "min-h-[88px] py-2.5", className)} {...props} />;
}
export function Select({ className, children, ...props }: SelectHTMLAttributes<HTMLSelectElement>) {
return (
<select className={cn(baseField, "h-10 appearance-none bg-[length:16px] pr-9", className)} {...props}>
{children}
</select>
);
}
export function Field({
label,
required,
hint,
error,
children,
className,
}: {
label?: string;
required?: boolean;
hint?: string;
error?: string;
children: React.ReactNode;
className?: string;
}) {
return (
<div className={className}>
{label ? (
<Label>
{label} {required ? <span className="text-danger-500">*</span> : null}
</Label>
) : null}
{children}
{error ? (
<p className="mt-1 text-[12px] text-danger-500">{error}</p>
) : hint ? (
<p className="mt-1 text-[12px] text-ink-500">{hint}</p>
) : null}
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { useRef, useState } from "react";
import { ImagePlus, Loader2, X } from "lucide-react";
import { cn } from "@/lib/utils";
export function MultiUpload({ name }: { name: string }) {
const [urls, setUrls] = useState<string[]>([]);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
async function onFiles(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []);
if (!files.length) return;
setUploading(true);
setError(null);
const next: string[] = [];
for (const file of files) {
try {
const body = new FormData();
body.append("file", file);
const res = await fetch("/api/uploads", { method: "POST", body });
const json = await res.json();
if (!res.ok) throw new Error(json?.error?.message ?? "Gagal upload.");
next.push(json.data.url);
} catch (err) {
setError(err instanceof Error ? err.message : "Gagal upload.");
}
}
setUrls((p) => [...p, ...next]);
setUploading(false);
if (inputRef.current) inputRef.current.value = "";
}
function remove(url: string) {
setUrls((p) => p.filter((u) => u !== url));
}
return (
<div>
{urls.map((u) => (
<input key={u} type="hidden" name={name} value={u} />
))}
<div className="flex flex-wrap gap-3">
{urls.map((u, i) => (
<div key={u} className="group relative h-24 w-32 overflow-hidden rounded-[14px] border border-ink-100">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={u} alt={`foto ${i + 1}`} className="h-full w-full object-cover" />
<button
type="button"
onClick={() => remove(u)}
className="absolute right-1 top-1 flex size-6 items-center justify-center rounded-full bg-ink-900/60 text-white hover:bg-ink-900"
aria-label="Hapus foto"
>
<X className="size-3.5" />
</button>
</div>
))}
<button
type="button"
onClick={() => inputRef.current?.click()}
className={cn(
"flex h-24 w-32 flex-col items-center justify-center gap-1 rounded-[14px] border border-dashed border-ink-300 bg-canvas text-ink-500 transition-colors hover:border-brand-500 hover:text-brand-600",
)}
>
{uploading ? <Loader2 className="size-5 animate-spin" /> : <ImagePlus className="size-5" />}
<span className="text-[11.5px]">{uploading ? "Mengunggah..." : "Tambah foto"}</span>
</button>
</div>
{error ? <p className="mt-1 text-[12px] text-danger-500">{error}</p> : null}
<p className="mt-1 text-[12px] text-ink-500">JPG/PNG/WebP, maks 5MB per foto.</p>
<input ref={inputRef} type="file" accept="image/*" multiple className="hidden" onChange={onFiles} />
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export function PageHeader({
title,
description,
actions,
className,
}: {
title: string;
description?: string;
actions?: ReactNode;
className?: string;
}) {
return (
<div className={cn("mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between", className)}>
<div>
<h1 className="text-[26px] font-extrabold leading-tight text-ink-900">{title}</h1>
{description ? <p className="mt-1 text-[13px] text-ink-500">{description}</p> : null}
</div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { cn } from "@/lib/utils";
export type RadioOption = { value: string; label: string; color?: string; icon?: string };
/** Pilihan berbentuk pill/segmented berbasis radio — tanpa JavaScript, aman untuk form server action. */
export function RadioPills({
name,
options,
defaultValue,
className,
columns,
}: {
name: string;
options: RadioOption[];
defaultValue?: string;
className?: string;
columns?: boolean;
}) {
return (
<div className={cn(columns ? "grid grid-cols-2 gap-2" : "flex flex-wrap gap-2", className)}>
{options.map((opt) => (
<label key={opt.value} className="cursor-pointer">
<input
type="radio"
name={name}
value={opt.value}
defaultChecked={defaultValue === opt.value}
className="peer sr-only"
/>
<span
className={cn(
"inline-flex w-full items-center justify-center gap-2 rounded-[12px] border border-ink-300 bg-surface px-3 py-2 text-[13px] font-medium text-ink-700 transition-all",
"hover:border-brand-300 hover:bg-brand-50",
"peer-checked:border-brand-500 peer-checked:bg-brand-50 peer-checked:text-brand-700 peer-checked:ring-2 peer-checked:ring-brand-500/20",
)}
>
{opt.color ? <span className="size-2 rounded-full" style={{ backgroundColor: opt.color }} /> : null}
{opt.label}
</span>
</label>
))}
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { cn } from "@/lib/utils";
export function StatCard({
label,
value,
icon,
gradient = "grad-primary",
delta,
hint,
href,
}: {
label: string;
value: ReactNode;
icon: ReactNode;
gradient?: string;
delta?: string;
hint?: string;
href?: string;
}) {
const body = (
<div className="flex h-full items-center gap-4 rounded-[20px] border border-ink-100 bg-surface p-4 shadow-[var(--shadow-sm)] transition-all duration-150 hover:-translate-y-0.5 hover:shadow-[var(--shadow-md)]">
<span className={cn("flex size-11 shrink-0 items-center justify-center rounded-[14px] text-white", gradient)}>
{icon}
</span>
<div className="min-w-0">
<p className="truncate text-[12px] font-medium text-ink-500">{label}</p>
<div className="flex items-baseline gap-2">
<p className="font-num text-[26px] font-bold leading-tight text-ink-900">{value}</p>
{delta ? <span className="text-[12px] font-semibold text-mint-500">{delta}</span> : null}
</div>
{hint ? <p className="truncate text-[11px] text-ink-500">{hint}</p> : null}
</div>
</div>
);
if (href) return <Link href={href}>{body}</Link>;
return body;
}
+15
View File
@@ -0,0 +1,15 @@
"use client";
import { useFormStatus } from "react-dom";
import { Loader2 } from "lucide-react";
import { Button, type ButtonProps } from "./button";
export function SubmitButton({ children, pendingText, ...props }: ButtonProps & { pendingText?: string }) {
const { pending } = useFormStatus();
return (
<Button type="submit" disabled={pending} {...props}>
{pending ? <Loader2 className="size-4 animate-spin" /> : null}
{pending ? (pendingText ?? "Menyimpan...") : children}
</Button>
);
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
import { useRef, useState } from "react";
import { Camera, Loader2, X } from "lucide-react";
import { cn } from "@/lib/utils";
export function UploadInput({
name,
defaultValue,
shape = "circle",
label = "Upload foto",
className,
}: {
name: string;
defaultValue?: string | null;
shape?: "circle" | "rect";
label?: string;
className?: string;
}) {
const [value, setValue] = useState(defaultValue ?? "");
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
setError(null);
try {
const body = new FormData();
body.append("file", file);
const res = await fetch("/api/uploads", { method: "POST", body });
const json = await res.json();
if (!res.ok) throw new Error(json?.error?.message ?? "Gagal upload.");
setValue(json.data.url);
} catch (err) {
setError(err instanceof Error ? err.message : "Gagal upload.");
} finally {
setUploading(false);
}
}
return (
<div className={cn("flex items-center gap-3", className)}>
<input type="hidden" name={name} value={value} />
<button
type="button"
onClick={() => inputRef.current?.click()}
className={cn(
"relative flex shrink-0 items-center justify-center overflow-hidden border border-dashed border-ink-300 bg-canvas text-ink-500 transition-colors hover:border-brand-500 hover:text-brand-600",
shape === "circle" ? "size-20 rounded-full" : "h-20 w-28 rounded-[14px]",
)}
>
{value ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={value} alt="preview" className="h-full w-full object-cover" />
) : uploading ? (
<Loader2 className="size-5 animate-spin" />
) : (
<Camera className="size-5" />
)}
</button>
<div className="min-w-0">
<button
type="button"
onClick={() => inputRef.current?.click()}
className="text-[13px] font-semibold text-brand-600 hover:underline"
>
{value ? "Ganti foto" : label}
</button>
<p className="text-[12px] text-ink-500">JPG/PNG/WebP, maks 5MB.</p>
{value ? (
<button
type="button"
onClick={() => setValue("")}
className="mt-0.5 inline-flex items-center gap-1 text-[12px] text-danger-500 hover:underline"
>
<X className="size-3" /> Hapus
</button>
) : null}
{error ? <p className="text-[12px] text-danger-500">{error}</p> : null}
</div>
<input ref={inputRef} type="file" accept="image/*" className="hidden" onChange={onFile} />
</div>
);
}