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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user