commit 61ce9d7926fe03bb9feb48320a6a53bdd4930808 Author: Amadea Date: Fri Sep 18 19:24:59 2026 +0700 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..141df84 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +DATABASE_URL="file:./dev.db" +AUTH_SECRET="ganti-dengan-random-32-char" +AUTH_TRUST_HOST=true +NEXT_PUBLIC_APP_NAME="Kodeva" +NEXT_PUBLIC_APP_URL="http://localhost:3000" +UPLOAD_DIR="./public/uploads" +MAX_UPLOAD_MB=5 +TZ="Asia/Jakarta" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e2a0d0a --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env.example + +# database (local SQLite) +prisma/*.db +prisma/*.db-journal +*.db +*.db-journal + +# uploaded files +public/uploads/ + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..003d75a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,608 @@ +# AGENTS.md — Kodeva + +> **Kodeva** — Sistem Manajemen Kursus Coding (Coding Course Management System). +> Dokumen ini adalah kontrak kerja untuk AI coding agent & developer. Baca sampai habis sebelum menulis kode. +> Untuk aturan visual/UX, lihat [`DESIGN.md`](./DESIGN.md). Untuk contoh data, lihat [`seed.example.ts`](./seed.example.ts). + +--- + +## 1. Ringkasan Produk + +Kodeva membantu akademi/bimbel coding mengelola siklus hidup siswa dari **calon student → student → ex student**, lengkap dengan: + +| # | Fitur | Ringkas | +|---|-------|---------| +| 1 | **CRUD Follow Up Siswa** | Data siswa: nama, no. telepon, email, foto profil, nama orang tua, sumber lead (WhatsApp / Referral / Banner / Social Media), status siswa | +| 2 | **Dashboard Right Rail** | Grafik siswa aktif, kehadiran, bar chart sumber lead | +| 3 | **Pembayaran Prorata** | Bayar per pertemuan, tanpa payment gateway, metode Cash / Transfer / QRIS | +| 4 | **Jurnal & Raport & Kehadiran** | Guru mengisi kehadiran, jurnal kelas, dan raport siswa | +| 5 | **Konfigurasi Kelas** | Kelas dapat dikonfigurasi; siswa dipilihkan kelas saat pendaftaran | +| 6 | **Jurnal Aktivitas** | Upload foto + aktivitas kelas | +| 7 | **Dashboard Ringkasan** | Berapa yang harus di-follow up, berapa ex student | +| 8 | **Placement Test** | Hasil tes penempatan tampil di detail siswa | + +**Pengguna:** Admin (pemilik/operator), Teacher (tutor). Bahasa UI: **Bahasa Indonesia**. + +--- + +## 2. Tech Stack (Default) + +``` +Runtime : Node.js 20+ / Bun +Framework : Next.js 15 (App Router, React Server Components) +Language : TypeScript (strict) +Styling : Tailwind CSS v4 + CSS variables (lihat DESIGN.md) +UI Kit : shadcn/ui + Radix Primitives + lucide-react +Charts : Recharts +Forms : React Hook Form + Zod (zodResolver) +Tables : TanStack Table +Drag & Drop : dnd-kit (kanban follow-up) +ORM : Prisma +Database : PostgreSQL 16 (dev cepat boleh SQLite, tapi schema final PostgreSQL) +Auth : Auth.js (NextAuth v5) — Credentials + RBAC +Upload : API route lokal `/api/uploads` → `public/uploads` (atau S3-compatible) +Dates : date-fns (+ locale `id`) +Testing : Vitest (unit) + Playwright (e2e) +Lint/Format : ESLint + Prettier +Package Mgr : pnpm +``` + +> Jika user memilih stack lain (mis. Laravel/Vue), **pertahankan domain model, business rules, dan DESIGN.md**, hanya ganti implementasi. Tanyakan dulu sebelum migrasi stack. + +--- + +## 3. Setup & Perintah + +```bash +pnpm install # install dependency +cp .env.example .env # siapkan env +pnpm db:push # prisma db push (dev) +pnpm db:seed # isi contoh data (seed.example.ts) +pnpm dev # jalankan dev server :3000 +pnpm build && pnpm start # production build + +pnpm lint # eslint +pnpm typecheck # tsc --noEmit +pnpm test # vitest +pnpm test:e2e # playwright +pnpm format # prettier --write . +``` + +**Definition of Done** setiap task: `pnpm lint && pnpm typecheck && pnpm test` hijau, tidak ada `any` liar, tidak ada secret ter-commit. + +--- + +## 4. Struktur Folder + +``` +kodeva/ +├─ AGENTS.md +├─ DESIGN.md +├─ seed.example.ts +├─ prisma/ +│ ├─ schema.prisma +│ └─ migrations/ +├─ public/ +│ └─ uploads/ # foto siswa, jurnal, bukti bayar +├─ src/ +│ ├─ app/ +│ │ ├─ (auth)/login/page.tsx +│ │ ├─ (app)/ +│ │ │ ├─ layout.tsx # shell: left nav + main + right rail +│ │ │ ├─ dashboard/page.tsx +│ │ │ ├─ follow-up/page.tsx # kanban +│ │ │ ├─ students/ +│ │ │ │ ├─ page.tsx +│ │ │ │ ├─ new/page.tsx +│ │ │ │ └─ [id]/page.tsx # detail: profil, kelas, kehadiran, bayar, raport, placement +│ │ │ ├─ classes/page.tsx +│ │ │ ├─ classes/[id]/page.tsx +│ │ │ ├─ attendance/page.tsx +│ │ │ ├─ payments/page.tsx +│ │ │ ├─ journals/page.tsx +│ │ │ ├─ report-cards/page.tsx +│ │ │ ├─ placements/page.tsx +│ │ │ └─ settings/page.tsx +│ │ └─ api/ +│ │ ├─ students/route.ts +│ │ ├─ students/[id]/route.ts +│ │ ├─ ... (lihat §8) +│ │ └─ uploads/route.ts +│ ├─ components/ +│ │ ├─ ui/ # shadcn primitives +│ │ ├─ layout/ # AppSidebar, RightRail, Topbar +│ │ ├─ students/ # StudentForm, StudentCard, StatusBadge +│ │ ├─ charts/ # ActiveStudentsChart, AttendanceChart, LeadSourceBar +│ │ ├─ payments/ # PaymentForm, ProrataSummary +│ │ └─ shared/ # EmptyState, StatCard, DataTable +│ ├─ lib/ +│ │ ├─ prisma.ts +│ │ ├─ auth.ts +│ │ ├─ rbac.ts +│ │ ├─ validators/ # zod schemas per entitas +│ │ ├─ prorata.ts # kalkulasi pembayaran (PURE FUNCTION + unit test) +│ │ ├─ follow-up.ts # query due follow up +│ │ └─ utils.ts # cn(), formatIDR(), formatDateID() +│ ├─ hooks/ +│ └─ types/ +└─ tests/ +``` + +**Aturan:** Server Components untuk baca data, Server Actions / Route Handlers untuk mutasi. Jangan fetch API internal dari Server Component — query Prisma langsung. + +--- + +## 5. Konvensi Kode + +- **TypeScript strict.** Dilarang `any`; pakai `unknown` + narrowing. `import type` untuk tipe. +- **Naming:** komponen `PascalCase`, file komponen `PascalCase.tsx`, util `camelCase.ts`, route segment `kebab-case`. +- **Validasi:** semua input user divalidasi Zod di boundary (form + route handler) memakai schema yang sama. +- **Uang:** simpan sebagai `Int` dalam **rupiah penuh** (tanpa sen). Jangan pakai `Float`. Format tampilan lewat `formatIDR()` → `Rp 2.000.000`. +- **Tanggal:** simpan UTC di DB, tampilkan dengan `date-fns` locale `id` (mis. `Senin, 18 Sep 2026`). Timezone default `Asia/Jakarta`. +- **ID:** `cuid()`. Jangan auto-increment untuk entitas publik. +- **Soft delete:** entitas siswa/kelas pakai `archivedAt`; jangan hard delete. +- **Query:** selalu scope query ke authorization. Jangan pernah mengembalikan data lintas-tenant tanpa cek role. +- **Error:** route handler mengembalikan `{ data }` atau `{ error: { code, message } }`; jangan bocorkan stack trace. +- **Commit:** Conventional Commits (`feat(students): ...`, `fix(prorata): ...`). + +--- + +## 6. Domain Model (Prisma) + +```prisma +generator client { provider = "prisma-client-js" } +datasource db { provider = "postgresql"; url = env("DATABASE_URL") } + +enum Role { ADMIN TEACHER } +enum StudentStatus { CALON_STUDENT STUDENT EX_STUDENT } +enum LeadSource { WHATSAPP REFERRAL BANNER SOCIAL_MEDIA } +enum FollowUpStatus { NEW CONTACTED TRIAL OFFER_SENT ENROLLED LOST } +enum Gender { MALE FEMALE } +enum ClassStatus { DRAFT ACTIVE COMPLETED ARCHIVED } +enum EnrollmentStatus { ACTIVE COMPLETED DROPPED } +enum AttendanceStatus { PRESENT LATE EXCUSED SICK ABSENT } +enum PaymentMethod { CASH TRANSFER QRIS } +enum PaymentStatus { UNPAID PARTIAL PAID REFUNDED } +enum PlacementLevel { BEGINNER INTERMEDIATE ADVANCED } + +model User { + id String @id @default(cuid()) + name String + email String @unique + passwordHash String + role Role @default(TEACHER) + avatarUrl String? + phone String? + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + classesTaught Class[] @relation("ClassTeacher") + sessions Session[] + journals Journal[] + reportCards ReportCard[] + placementTests PlacementTest[] @relation("Examiner") + followUpsOwned FollowUp[] @relation("FollowUpPIC") +} + +model Student { + id String @id @default(cuid()) + fullName String + phone String + email String? @unique + photoUrl String? + parentName String? + parentPhone String? + birthDate DateTime? + gender Gender? + address String? + leadSource LeadSource + status StudentStatus @default(CALON_STUDENT) + notes String? + joinedAt DateTime? + archivedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + enrollments Enrollment[] + attendances Attendance[] + payments Payment[] + reportCards ReportCard[] + placementTests PlacementTest[] + followUps FollowUp[] + journalStudents JournalStudent[] +} + +model Class { + id String @id @default(cuid()) + name String + code String @unique + description String? + level PlacementLevel + teacherId String? + teacher User? @relation("ClassTeacher", fields: [teacherId], references: [id]) + totalMeetings Int @default(10) + packagePrice Int @default(0) // harga paket penuh (IDR) + pricePerMeeting Int? // override opsional + capacity Int @default(12) + schedule String? // "Senin & Rabu 16:00-17:30" + startDate DateTime? + endDate DateTime? + status ClassStatus @default(ACTIVE) + color String? // hex warna kartu kelas + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + enrollments Enrollment[] + sessions Session[] + payments Payment[] + journals Journal[] + reportCards ReportCard[] +} + +model Enrollment { + id String @id @default(cuid()) + studentId String + student Student @relation(fields: [studentId], references: [id], onDelete: Cascade) + classId String + class Class @relation(fields: [classId], references: [id], onDelete: Cascade) + status EnrollmentStatus @default(ACTIVE) + joinedAt DateTime @default(now()) + leftAt DateTime? + createdAt DateTime @default(now()) + @@unique([studentId, classId]) +} + +model Session { + id String @id @default(cuid()) + classId String + class Class @relation(fields: [classId], references: [id], onDelete: Cascade) + meetingNumber Int + date DateTime + topic String? + teacherId String? + teacher User? @relation(fields: [teacherId], references: [id]) + createdAt DateTime @default(now()) + attendances Attendance[] + journals Journal[] + @@unique([classId, meetingNumber]) +} + +model Attendance { + id String @id @default(cuid()) + sessionId String + session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade) + studentId String + student Student @relation(fields: [studentId], references: [id], onDelete: Cascade) + status AttendanceStatus + note String? + recordedById String? + createdAt DateTime @default(now()) + @@unique([sessionId, studentId]) +} + +model Payment { + id String @id @default(cuid()) + invoiceNumber String @unique + studentId String + student Student @relation(fields: [studentId], references: [id]) + classId String + class Class @relation(fields: [classId], references: [id]) + meetingsPaid Int + pricePerMeeting Int + amount Int // meetingsPaid * pricePerMeeting + method PaymentMethod + status PaymentStatus @default(PAID) + paidAt DateTime + note String? + proofUrl String? + createdById String? + createdAt DateTime @default(now()) +} + +model Journal { + id String @id @default(cuid()) + classId String + class Class @relation(fields: [classId], references: [id], onDelete: Cascade) + sessionId String? + session Session? @relation(fields: [sessionId], references: [id]) + title String + description String? + activityDate DateTime + tags String[] + photos JournalPhoto[] + students JournalStudent[] + authorId String + author User @relation(fields: [authorId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model JournalPhoto { + id String @id @default(cuid()) + journalId String + journal Journal @relation(fields: [journalId], references: [id], onDelete: Cascade) + url String + caption String? + sortOrder Int @default(0) +} + +model JournalStudent { + journalId String + studentId String + journal Journal @relation(fields: [journalId], references: [id], onDelete: Cascade) + student Student @relation(fields: [studentId], references: [id], onDelete: Cascade) + @@id([journalId, studentId]) +} + +model ReportCard { + id String @id @default(cuid()) + studentId String + student Student @relation(fields: [studentId], references: [id], onDelete: Cascade) + classId String + class Class @relation(fields: [classId], references: [id]) + period String // "2026-Term1" + teacherId String + teacher User @relation(fields: [teacherId], references: [id]) + attendanceSummary String? + finalScore Int? + grade String? // A / B / C / D + teacherNotes String? + publishedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + scores ReportScore[] +} + +model ReportScore { + id String @id @default(cuid()) + reportCardId String + reportCard ReportCard @relation(fields: [reportCardId], references: [id], onDelete: Cascade) + aspect String // "Logika", "Computational Thinking", "Problem Solving", "Kreativitas", "Kolaborasi" + score Int // 0-100 + note String? +} + +model PlacementTest { + id String @id @default(cuid()) + studentId String + student Student @relation(fields: [studentId], references: [id], onDelete: Cascade) + testDate DateTime + levelResult PlacementLevel + recommendedClassId String? + totalScore Int + examinerId String? + examiner User? @relation("Examiner", fields: [examinerId], references: [id]) + notes String? + createdAt DateTime @default(now()) + scores PlacementScore[] +} + +model PlacementScore { + id String @id @default(cuid()) + placementTestId String + placementTest PlacementTest @relation(fields: [placementTestId], references: [id], onDelete: Cascade) + category String // "Logika", "Dasar Coding", "Problem Solving", "Kreativitas" + score Int // 0-100 +} + +model FollowUp { + id String @id @default(cuid()) + studentId String + student Student @relation(fields: [studentId], references: [id], onDelete: Cascade) + status FollowUpStatus @default(NEW) + picId String? + pic User? @relation("FollowUpPIC", fields: [picId], references: [id]) + nextFollowUpAt DateTime? + lastContactedAt DateTime? + channel LeadSource? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + activities FollowUpActivity[] +} + +model FollowUpActivity { + id String @id @default(cuid()) + followUpId String + followUp FollowUp @relation(fields: [followUpId], references: [id], onDelete: Cascade) + type String // call | wa | email | meeting + note String + authorId String? + createdAt DateTime @default(now()) +} + +model Setting { + key String @id + value Json +} +``` + +**Relasi penting:** `Student 1—N Enrollment N—1 Class`. Satu siswa bisa ikut beberapa kelas. Kehadiran terikat ke `Session` (pertemuan), bukan langsung ke kelas — ini yang membuat prorata per pertemuan akurat. + +--- + +## 7. Business Rules (WAJIB, sertakan unit test) + +### 7.1 Prorata Pembayaran — `src/lib/prorata.ts` +Pembayaran dihitung **per pertemuan**, bukan per bulan. + +```ts +pricePerMeeting = klass.pricePerMeeting ?? Math.round(klass.packagePrice / klass.totalMeetings) +amount = meetingsPaid * pricePerMeeting +``` + +Contoh: paket **Rp 2.000.000 / 10 pertemuan** → `pricePerMeeting = 200.000`. +- Bayar 10 pertemuan → `amount = 2.000.000` (PAID penuh). +- Bayar 5 pertemuan → `amount = 1.000.000` (PAID, sisa 5 sesi). +- Cicilan: `Payment.status = PARTIAL` bila akumulasi `meetingsPaid` < komitmen, lalu naik ke `PAID`. + +**Nilai prorata saat siswa berhenti pindah/refund:** +```ts +usedMeetings = count(Attendance WHERE studentId = X AND status IN (PRESENT, LATE)) +paidMeetings = sum(Payment.meetingsPaid WHERE status != REFUNDED) +remainingMeetings = max(0, paidMeetings - usedMeetings) +proratedValue = remainingMeetings * pricePerMeeting // nilai sisa / refund +``` + +**Aturan sesi:** 1 pembayaran = N pertemuan. Kuota sesi siswa = total `meetingsPaid`. Sesi terpakai = kehadiran `PRESENT`/`LATE`. Jika sesi terpakai > kuota → tandai `UNPAID` & tampilkan badge **"Perlu Perpanjang"**. + +### 7.2 Follow Up Due — `src/lib/follow-up.ts` +```ts +followUpDue = FollowUp.findMany({ + where: { + status: { notIn: ["ENROLLED", "LOST"] }, + nextFollowUpAt: { lte: endOfToday() }, + }, +}) +``` +- Angka **"Perlu Follow Up"** di dashboard = `count(followUpDue)`. +- Angka **"Ex Student"** = `count(Student where status = EX_STUDENT)`. +- Kanban follow-up: kolom `NEW → CONTACTED → TRIAL → OFFER_SENT → ENROLLED`; `LOST` di kolom terpisah. +- Saat follow-up diubah ke `ENROLLED`, tawarkan form: ubah `Student.status` jadi `STUDENT`, pilih kelas, buat `Enrollment`, lalu arahkan ke input `Payment`. + +### 7.3 Kehadiran +- `attendanceRate = (PRESENT + LATE) / totalSesiTercatat * 100`. +- `LATE` dihitung hadir tapi ditandai kuning. +- Guru mengisi kehadiran per `Session` (halaman `attendance`), bisa bulk (Hadir semua) lalu ubah pengecualian. + +### 7.4 Status Siswa +``` +CALON_STUDENT --(enroll + bayar)--> STUDENT --(selesai kelas / berhenti)--> EX_STUDENT + ^ | + +---------- (daftar lagi) ----------------+ +``` +Perubahan status manual oleh Admin harus tercatat di activity/audit. + +### 7.5 Placement Test +- Menyimpan skor per kategori (0–100) → `levelResult` (`BEGINNER`/`INTERMEDIATE`/`ADVANCED`) + rekomendasi kelas. +- Hasil **selalu tampil** di tab "Placement Test" pada halaman detail siswa dan sebagai badge di header profil. + +### 7.6 Kelas +- Admin bisa CRUD kelas: nama, kode unik, level, guru, jumlah pertemuan, harga paket, kapasitas, jadwal, warna. +- Siswa baru **wajib memilih kelas** (minimal 1) saat diubah menjadi `STUDENT`. Validasi kapasitas: tolak jika `enrollments.active >= capacity`. +- Generate `Session` otomatis sebanyak `totalMeetings` dengan interval jadwal saat kelas diaktifkan (boleh manual). + +--- + +## 8. API Endpoints + +| Method | Route | Fungsi | Role | +|--------|-------|--------|------| +| GET/POST | `/api/students` | list (filter status/source/q) & create | ADMIN, TEACHER | +| GET/PATCH/DELETE | `/api/students/[id]` | detail, update, archive | ADMIN, TEACHER | +| POST | `/api/students/[id]/enroll` | enroll ke kelas | ADMIN | +| GET/POST | `/api/follow-ups` | list (due) & create | ADMIN, TEACHER | +| PATCH | `/api/follow-ups/[id]` | ubah status / jadwal / PIC | ADMIN, TEACHER | +| POST | `/api/follow-ups/[id]/activities` | catat aktivitas follow up | ADMIN, TEACHER | +| GET/POST | `/api/classes` | list & create kelas | ADMIN | +| GET/PATCH/DELETE | `/api/classes/[id]` | detail/update/archive | ADMIN | +| POST | `/api/classes/[id]/sessions` | generate/atur pertemuan | ADMIN, TEACHER | +| GET/POST | `/api/attendance` | per session & bulk upsert | ADMIN, TEACHER | +| GET/POST | `/api/payments` | list & buat pembayaran (prorata) | ADMIN | +| PATCH | `/api/payments/[id]` | update status / refund | ADMIN | +| GET/POST | `/api/journals` | feed & create (multipart foto) | ADMIN, TEACHER | +| GET/PATCH/DELETE | `/api/journals/[id]` | detail/update/hapus | ADMIN, TEACHER | +| GET/POST | `/api/report-cards` | list & create | ADMIN, TEACHER | +| PATCH | `/api/report-cards/[id]/publish` | publish ke siswa | ADMIN, TEACHER | +| GET/POST | `/api/placements` | list & create hasil placement | ADMIN, TEACHER | +| GET | `/api/dashboard/summary` | angka + data grafik right rail | ADMIN, TEACHER | +| POST | `/api/uploads` | upload file → URL | ADMIN, TEACHER | + +Semua route pakai `withAuth(handler, { roles })` dari `src/lib/rbac.ts`. Validasi body dengan Zod. Response sukses `{ data }`, error `{ error: { code, message } }`. + +--- + +## 9. RBAC + +| Area | ADMIN | TEACHER | +|------|:-----:|:-------:| +| Dashboard & right rail | ✅ | ✅ | +| CRUD siswa | ✅ | ✅ (tanpa archive) | +| CRUD follow up | ✅ | ✅ | +| CRUD kelas & setting | ✅ | 👁 baca saja | +| Input kehadiran | ✅ | ✅ (kelas sendiri) | +| Input jurnal & foto | ✅ | ✅ (kelas sendiri) | +| Input raport | ✅ | ✅ (kelas sendiri) | +| Input placement test | ✅ | ✅ | +| Pembayaran | ✅ | 👁 baca saja | +| Manajemen user | ✅ | ❌ | + +--- + +## 10. Environment Variables + +```env +DATABASE_URL="postgresql://user:pass@localhost:5432/kodeva" +AUTH_SECRET="ganti-dengan-random-32-char" +AUTH_TRUST_HOST=true +NEXT_PUBLIC_APP_NAME="Kodeva" +NEXT_PUBLIC_APP_URL="http://localhost:3000" +UPLOAD_DIR="./public/uploads" +MAX_UPLOAD_MB=5 +TZ="Asia/Jakarta" +``` + +Jangan pernah commit `.env`. Sediakan `.env.example`. + +--- + +## 11. Contoh Data + +Dataset contoh lengkap ada di [`seed.example.ts`](./seed.example.ts): 3 user (1 admin, 2 teacher), 5 kelas, 10 siswa (campuran `CALON_STUDENT` / `STUDENT` / `EX_STUDENT`), enrollment, sesi, kehadiran, pembayaran prorata, jurnal + foto, raport, placement test, dan follow up. + +Ringkasan untuk konteks cepat: + +**Kelas (5):** +| Kode | Nama | Level | Pertemuan | Harga Paket | Rp/Pertemuan | +|------|------|-------|-----------|-------------|--------------| +| KOD-SCR-01 | Scratch Junior | Beginner | 8 | Rp 1.200.000 | Rp 150.000 | +| KOD-PYT-01 | Python Dasar | Beginner | 10 | Rp 2.000.000 | Rp 200.000 | +| KOD-WEB-01 | Web Dev: HTML & CSS | Beginner | 12 | Rp 2.400.000 | Rp 200.000 | +| KOD-JS-01 | JavaScript Intermediate | Intermediate | 10 | Rp 2.500.000 | Rp 250.000 | +| KOD-ARD-01 | Arduino & Robotics | Intermediate | 8 | Rp 1.800.000 | Rp 225.000 | + +**Siswa (10):** Andi Pratama (STUDENT), Citra Lestari (CALON_STUDENT), Dimas Anggara (STUDENT), Elsa Maharani (CALON_STUDENT), Fajar Nugroho (STUDENT), Gita Permata (EX_STUDENT), Hadi Susanto (STUDENT), Intan Puspita (CALON_STUDENT), Joko Wijaya (STUDENT), Kirana Dewi (EX_STUDENT). + +**Contoh prorata:** Andi bayar Rp 2.000.000 untuk 10 pertemuan Python Dasar → Rp 200.000/pertemuan. Hadir 6 → terpakai Rp 1.200.000, sisa 4 pertemuan (Rp 800.000). + +--- + +## 12. Testing & Quality + +- **Unit wajib** untuk `prorata.ts`, `follow-up.ts`, `utils` format uang/tanggal, dan semua Zod schema. +- **Integration** untuk route handler (happy path + validasi gagal + unauthorized). +- **E2E** minimal: login → tambah calon siswa → follow up → enroll + bayar prorata → isi kehadiran → isi jurnal → isi raport → lihat placement test di detail siswa. +- Sertakan test case batas: `totalMeetings = 0`, `meetingsPaid > totalMeetings`, pembayaran parsial, kehadiran melebihi kuota. + +--- + +## 13. Do / Don't untuk Agent + +**DO** +- Baca `DESIGN.md` sebelum membuat/mengubah UI. Pakai token warna & komponen yang sudah ada, jangan hardcode hex baru. +- Jalankan `prisma generate` setelah mengubah schema. +- Buat komponen kecil & reusable; jangan file 800 baris. +- Tangani 4 state: **loading (skeleton), empty, error, success**. +- Aksesibel: label form, `aria-*`, fokus terlihat, kontras AA. +- Format uang & tanggal lewat util, jangan inline. + +**DON'T** +- Jangan tambahkan payment gateway / integrasi pihak ketiga pembayaran. +- Jangan pakai `Float` untuk uang. +- Jangan fetch internal API dari Server Component. +- Jangan hapus data keras; gunakan archive. +- Jangan ubah `DESIGN.md`/palet tanpa persetujuan user. +- Jangan taruh logika prorata di komponen; selalu di `lib/prorata.ts` + test. + +--- + +## 14. Roadmap + +- [ ] **M1** Auth + shell layout (left nav, right rail) + Prisma schema + seed +- [ ] **M2** CRUD Siswa + Follow Up kanban + status +- [ ] **M3** CRUD Kelas + Enrollment + Sesi/Pertemuan +- [ ] **M4** Kehadiran + Pembayaran prorata +- [ ] **M5** Jurnal + upload foto +- [ ] **M6** Raport + Placement Test +- [ ] **M7** Dashboard right rail (semua grafik) + polish +- [ ] **M8** Export, notifikasi WhatsApp manual, audit log diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..2e925b5 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,458 @@ +# DESIGN.md — Kodeva Design System + +> Panduan visual & UX untuk **Kodeva**. Tema: **Colorful · Ed-Tech · Playful · Rapi (bukan childish)**. +> Bayangkan antarmuka produk SaaS modern untuk akademi coding — ceria, berenergi, tapi tetap kredibel dan enak dipakai kerja 8 jam. + +--- + +## 1. Brand & Personality + +| Atribut | Nilai | +|---------|-------| +| Nama | **Kodeva** | +| Tagline | *"Kelola kelas coding, tumbuhkan pembuat masa depan."* | +| Karakter | Ceria, cerdas, hangat, terpercaya, sedikit jenaka | +| Bukan | Kaku korporat, norak, kekanak-kanakan (tanpa kartun balita / font comic) | +| Metafora | Papan tulis digital yang penuh warna, blok kode berwarna, sticky notes | + +**Referensi rasa:** Linear (kerapian) × Duolingo (keceriaan) × Notion (keruang) × Figma (warna). + +--- + +## 2. Design Principles + +1. **Warna membawa makna, bukan sekadar hiasan.** Tiap warna punya peran semantik (status, sumber lead, kehadiran). Konsisten di seluruh app. +2. **Playful lewat bentuk & gerak, bukan lewat font kekanak-kanakan.** Sudut membulat, gradient halus, blob dekoratif tipis, micro-animation. +3. **Data dulu.** Angka penting (perlu follow up, siswa aktif) langsung terlihat tanpa klik. +4. **Satu layar, satu fokus.** Hierarki jelas: judul → aksi utama → konten → detail. +5. **Hangat & manusiawi.** Foto profil bulat, sapaan personal ("Hai, Sari 👋"), bahasa Indonesia santai-profesional. + +--- + +## 3. Color System + +### 3.1 Brand Palette + +| Token | Hex | Preview | Pemakaian | +|-------|-----|---------|-----------| +| `--brand-50` | `#F2EFFE` | 🟪 | background lembut | +| `--brand-100` | `#E4DEFD` | 🟪 | chip, hover | +| `--brand-300` | `#B9A9FB` | 🟪 | border aktif | +| `--brand-500` | `#7C5CFC` | 🟪 | **primary**, tombol, ikon aktif | +| `--brand-600` | `#6A46EF` | 🟪 | hover primary | +| `--brand-700` | `#5533CE` | 🟪 | pressed / teks di bg terang | + +### 3.2 Accent & Semantic + +| Token | Hex | Makna | +|-------|-----|-------| +| `--coral-500` | `#FF7A59` | aksi sekunder, highlight, CTA promo | +| `--amber-500` | `#FFB020` | warning, `CALON_STUDENT`, banner | +| `--mint-500` | `#12B886` | success, `STUDENT` aktif, hadir | +| `--sky-500` | `#2AA9FF` | info, social media, link | +| `--pink-500` | `#F65FA7` | aksen dekoratif, tag kreatif | +| `--danger-500` | `#F04438` | error, alpa, hapus | +| `--violet-soft` | `#A78BFA` | grafik pelengkap | + +### 3.3 Neutral + +| Token | Hex | Pemakaian | +|-------|-----|-----------| +| `--ink-900` | `#131A2B` | teks utama, heading | +| `--ink-700` | `#3A4560` | teks sekunder | +| `--ink-500` | `#6B7590` | placeholder, caption | +| `--ink-300` | `#C4CAD8` | border | +| `--ink-100` | `#EEF1F7` | divider, bg hover | +| `--surface` | `#FFFFFF` | kartu | +| `--canvas` | `#F7F8FC` | background halaman | +| `--canvas-tint` | linear-gradient(180deg,#F7F8FC 0%,#F1F0FE 100%) | background app | + +### 3.4 Gradient (signature) + +```css +--grad-primary: linear-gradient(135deg, #7C5CFC 0%, #2AA9FF 100%); /* tombol utama, hero */ +--grad-coral: linear-gradient(135deg, #FF7A59 0%, #FFB020 100%); /* kartu highlight */ +--grad-mint: linear-gradient(135deg, #12B886 0%, #2AA9FF 100%); /* stat positif */ +--grad-pink: linear-gradient(135deg, #F65FA7 0%, #7C5CFC 100%); /* dekorasi */ +``` + +Gunakan gradient **hanya** untuk: tombol primary, avatar ring, header kartu statistik, dan 1 blob dekoratif per halaman. Jangan gradient di teks panjang. + +### 3.5 Warna Mapping Semantik (WAJIB konsisten) + +**Status Siswa** +| Status | Warna | Badge | +|--------|-------|-------| +| `CALON_STUDENT` | Amber `#FFB020` | bg `#FFF4E0`, teks `#9A6100` | +| `STUDENT` | Mint `#12B886` | bg `#E4F8F1`, teks `#0A7A58` | +| `EX_STUDENT` | Slate `#6B7590` | bg `#EEF1F7`, teks `#4A5470` | + +**Sumber Lead** (dipakai di bar chart & chip) +| Sumber | Warna | Ikon | +|--------|-------|------| +| `WHATSAPP` | `#22C55E` | `MessageCircle` | +| `REFERRAL` | `#7C5CFC` | `Users` | +| `BANNER` | `#FFB020` | `Flag` | +| `SOCIAL_MEDIA` | `#2AA9FF` | `Instagram` | + +**Kehadiran** +| Status | Warna | Label | +|--------|-------|-------| +| `PRESENT` | Mint `#12B886` | Hadir | +| `LATE` | Amber `#FFB020` | Terlambat | +| `EXCUSED` | Sky `#2AA9FF` | Izin | +| `SICK` | Violet `#A78BFA` | Sakit | +| `ABSENT` | Danger `#F04438` | Alpa | + +**Follow Up** : NEW `#94A3B8` · CONTACTED `#2AA9FF` · TRIAL `#7C5CFC` · OFFER_SENT `#FFB020` · ENROLLED `#12B886` · LOST `#F04438` + +**Metode Bayar** : CASH `#12B886` · TRANSFER `#2AA9FF` · QRIS `#7C5CFC` + +--- + +## 4. Typography + +| Peran | Font | Weight | Size | +|-------|------|--------|------| +| Display / H1 | **Plus Jakarta Sans** | 800 | 30–36px, tracking `-0.02em` | +| H2 | Plus Jakarta Sans | 700 | 24px | +| H3 / Card title | Plus Jakarta Sans | 600 | 18px | +| Body | **Inter** | 400/500 | 14–15px, line-height 1.6 | +| Caption / label | Inter | 500 | 12–13px, tracking `0.01em` | +| Angka dashboard | **Sora** | 700 | 28–32px, tabular-nums | +| Kode / invoice | **JetBrains Mono** | 500 | 13px | + +``` +font-display : "Plus Jakarta Sans", Inter, system-ui, sans-serif +font-body : "Inter", system-ui, sans-serif +font-num : "Sora", Inter, sans-serif +font-mono : "JetBrains Mono", ui-monospace, monospace +``` + +Aturan: heading **selalu** gelap (`--ink-900`), jangan pakai warna brand untuk heading panjang. Warna brand untuk **aksen kata** (mis. "3 siswa **perlu follow up**"). + +--- + +## 5. Spacing · Radius · Shadow + +- **Spacing scale:** 4 · 8 · 12 · 16 · 20 · 24 · 32 · 40 · 48 · 64 (kelipatan 4). +- **Radius:** `sm 8px` (input kecil) · `md 12px` (button, input) · `lg 16px` (dropdown) · `xl 20px` (card) · `2xl 24px` (panel besar) · `full` (pill, avatar). +- **Shadow:** +```css +--shadow-xs: 0 1px 2px rgba(19,26,43,.06); +--shadow-sm: 0 2px 8px rgba(19,26,43,.06); +--shadow-md: 0 8px 24px rgba(19,26,43,.08); +--shadow-lg: 0 16px 40px rgba(19,26,43,.10); +--shadow-brand: 0 8px 20px rgba(124,92,252,.28); /* tombol primary */ +``` +- **Border:** 1px `--ink-100`; kartu aktif 1.5px `--brand-300`. +- **Focus ring:** `0 0 0 3px rgba(124,92,252,.28)`. + +--- + +## 6. Iconography & Illustration + +- **Ikon:** `lucide-react`, stroke 2, ukuran 18/20/24. Selalu dalam container bulat saat jadi aksi. +- **Ilustrasi/dekorasi:** bentuk blob, lingkaran, blok kode melayang — **opacity 6–12%**, gradient brand. Maksimal 1–2 elemen per halaman, jangan menghalangi konten. +- **Avatar:** bulat penuh, ukuran 32/40/56/96px, ring gradient `--grad-primary` untuk siswa aktif. +- **Emoji:** boleh untuk sapaan & empty state yang ramah, maksimal 1 per layar. Jangan di tombol/heading formal. + +--- + +## 7. Layout & Navigasi + +### 7.1 Shell Desktop (≥1280px) — 3 kolom + +``` +┌──────────────┬────────────────────────────────────────────┬───────────────────────┐ +│ LEFT NAV │ MAIN CONTENT │ RIGHT RAIL │ +│ 248px │ flex-1 (min 640px) │ 340px (sticky) │ +│ │ │ │ +│ ✦ Kodeva │ Topbar: breadcrumb · search · +Aksi · 🔔 │ ┌─────────────────┐ │ +│ │ │ │ Siswa Aktif 128│ │ +│ ▸ Dashboard │ ┌──────────────────────────────────────┐ │ ├─────────────────┤ │ +│ ▸ Follow Up │ │ Konten halaman / tabel / kanban │ │ │ Perlu Follow 12 │ │ +│ (12) 🔴 │ │ │ │ ├─────────────────┤ │ +│ ▸ Siswa │ │ │ │ │ Ex Student 7 │ │ +│ ▸ Kelas │ │ │ │ └─────────────────┘ │ +│ ▸ Kehadiran │ └──────────────────────────────────────┘ │ ┌─────────────────┐ │ +│ ▸ Pembayaran│ │ │ Kehadiran (donut)│ │ +│ ▸ Jurnal │ │ └─────────────────┘ │ +│ ▸ Raport │ │ ┌─────────────────┐ │ +│ ▸ Placement │ │ │ Siswa Aktif (line)│ │ +│ ▸ Pengaturan│ │ └─────────────────┘ │ +│ │ │ ┌─────────────────┐ │ +│ 👤 Sari W. │ │ │ Sumber Lead (bar)│ │ +│ Admin │ │ └─────────────────┘ │ +└──────────────┴────────────────────────────────────────────┴───────────────────────┘ +``` + +- **Left nav:** background `--surface`, lebar 248px, item aktif = pill gradient brand dengan teks putih + `--shadow-brand`. Badge angka merah untuk follow up due. Dapat collapse jadi 72px (ikon saja). +- **Right rail ("Insight Panel"):** hanya muncul penuh di **Dashboard**; di halaman lain bisa dibuka lewat tombol toggle (`PanelRight`), default tersembunyi agar konten lega. Sticky, scroll independen, tiap widget = kartu `radius xl`. +- **Topbar:** tinggi 64px, sticky, blur backdrop `rgba(247,248,252,.8)`. + +### 7.2 Breakpoint + +| Breakpoint | Perilaku | +|-----------|----------| +| `≥1440px` | 3 kolom penuh, rail 340px | +| `1280–1439px` | 3 kolom, rail 300px | +| `1024–1279px` | rail jadi drawer kanan (default tutup) | +| `768–1023px` | left nav collapse ikon; rail drawer | +| `<768px` | bottom nav 5 item utama; tabel → kartu; rail = sheet | + +### 7.3 Bottom Nav (mobile) +`Dashboard · Siswa · Follow Up · Kelas · Lainnya` + +--- + +## 8. Komponen + +### 8.1 Tombol +| Varian | Style | +|--------|-------| +| Primary | `--grad-primary`, teks putih, radius md, `--shadow-brand`, hover naik 1px | +| Secondary | bg `--brand-50`, teks `--brand-700` | +| Outline | border `--ink-300`, teks `--ink-900` | +| Ghost | transparan, hover `--ink-100` | +| Danger | bg `#F04438`, teks putih | +| Pill/Chip | radius full, padding 6×12 | + +Ukuran: `sm 32px` · `md 40px` · `lg 48px`. Sertakan ikon kiri untuk aksi utama. + +### 8.2 Kartu +- **Stat Card:** ikon bulat gradient + label caption + angka besar (`font-num`) + delta (`+8%` mint / `-3%` coral). Tinggi 112px. +- **Content Card:** header (judul + aksi "Lihat semua") + body + padding 20–24px. +- **Student Card:** avatar + nama + badge status + chip sumber + info kelas; hover shadow-md, cursor pointer. +- **Class Card:** strip warna kelas di kiri, nama, level chip, guru, `6/12 siswa` progress bar, jadwal. + +### 8.3 Form +- Label di atas, 13px `--ink-700`, wajib ada `*` coral untuk required. +- Input tinggi 40px, radius md, border `--ink-300`, fokus ring brand. +- **Upload foto profil:** dropzone lingkaran dengan preview + tombol kamera; maks 5MB, JPG/PNG/WebP. +- Pilihan sumber lead & status: **segmented control** berikon (bukan select biasa) agar playful. +- Validasi inline: border danger + pesan 12px di bawah field. + +### 8.4 Badge & Chip +- Status siswa pakai badge pill (dot + label). +- Sumber lead pakai chip berikon dengan warna mapping §3.5. +- Level kelas: Beginner (mint), Intermediate (sky), Advanced (violet). + +### 8.5 Tabel +Header `--ink-100`, teks uppercase 12px `--ink-500`. Baris hover `--brand-50/50`, tinggi 56px. Kolom aksi sticky kanan. Di mobile berubah jadi kartu bertumpuk. + +### 8.6 Kanban Follow Up +Kolom berisi header warna status + count + total value; kartu bisa drag (dnd-kit), sudut membulat, tampilkan avatar, sumber, "due 2 hari lalu" merah. + +### 8.7 Empty · Loading · Error +- **Empty:** ilustrasi blob + judul ramah ("Belum ada calon siswa nih") + CTA. +- **Loading:** skeleton shimmer bentuk sama dengan konten, bukan spinner penuh layar. +- **Error:** kartu coral lembut + pesan jelas + tombol "Coba lagi". + +--- + +## 9. Charts & Data Viz + +Semua chart pakai **Recharts**, tinggi 180–220px di rail, kartu putih, grid horizontal tipis `--ink-100`, tanpa garis sumbu Y penuh. + +| Chart | Tipe | Warna | Isi | +|-------|------|-------|-----| +| **Siswa Aktif** | Area/donut mini | `--grad-primary` | Tren 6 bulan; angka besar di tengah | +| **Kehadiran** | Donut / radial | PRESENT mint, LATE amber, ABSENT danger | % kehadiran hari ini + ring | +| **Sumber Lead** | Bar horizontal | mapping §3.5 | Jumlah per WhatsApp/Referral/Banner/Social | +| **Siswa per Kelas** | Bar vertikal | `--violet-soft` | Kapasitas terpakai | +| **Pendapatan** | Line/area | `--mint-500` | Opsional di halaman pembayaran | + +Aturan chart: +- Tooltip rounded, shadow-md, label Bahasa Indonesia. +- Angka `font-num`, animasi masuk 400ms ease-out. +- Selalu sediakan legend berwarna + label teks (jangan andalkan warna saja). +- Bar chart sumber lead: tampilkan angka di ujung bar. + +### 9.1 Right Rail Widgets (Dashboard) +1. **Stat stack:** Siswa Aktif · Perlu Follow Up · Ex Student (3 kartu mini). +2. **Kehadiran Hari Ini:** donut + `86%` + rincian Hadir/Terlambat/Alpa. +3. **Tren Siswa Aktif:** area chart 6 bulan. +4. **Sumber Lead:** bar chart 4 kategori. +5. **Perlu Follow Up Hari Ini:** list 4 nama + tombol WhatsApp; link "Lihat semua". +6. **Kelas Hari Ini:** sesi + jam + guru. + +--- + +## 10. Spesifikasi Halaman + +### 10.1 Dashboard +Hero sapaan + 4 stat card besar (Siswa Aktif, Perlu Follow Up, Ex Student, Kehadiran %). Bawahnya: grafik Tren Siswa Aktif & Sumber Lead (2 kolom). Right rail = widget §9.1. + +### 10.2 Halaman Siswa (list) +- Filter segmented: **Semua · Calon Student · Student · Ex Student**. +- Search nama/email/telepon, filter sumber lead, filter kelas. +- Toggle tampilan **tabel ⇄ kartu**. +- Tombol "+ Tambah Siswa" primary. +- Kolom: Siswa (avatar+nama+email) · Telepon · Orang Tua · Kelas · Sumber · Status · Aksi. + +### 10.3 Form Tambah/Edit Siswa +Grid 2 kolom: +- Kiri: Foto profil (dropzone), Nama lengkap*, No. telepon*, Email, Nama orang tua, No. telepon orang tua, Tanggal lahir, Gender. +- Kanan: **Tahu dari mana*** (segmented: WhatsApp/Referral/Banner/Social Media), **Status*** (segmented: Calon/Student/Ex), **Pilih Kelas** (multi-select, hanya aktif bila status Student), Catatan. +- Sticky footer: Batal · Simpan. + +### 10.4 Detail Siswa +Header profil: avatar besar ring gradient, nama, badge status, chip sumber, tombol WhatsApp/Call/Edit. Tab: **Ringkasan · Kelas & Kehadiran · Pembayaran · Placement Test · Raport · Jurnal**. +- **Placement Test** selalu tampil sebagai kartu: level hasil, skor per kategori (bar), rekomendasi kelas, tanggal, pemeriksa. +- **Pembayaran**: ringkasan prorata — `Kuota 10 sesi · Terpakai 6 · Sisa 4 · Nilai sisa Rp 800.000`. + +### 10.5 Kelas (CRUD) +Grid kartu kelas dengan strip warna + progress kapasitas. Form: nama, kode, level, guru, total pertemuan, harga paket, kapasitas, jadwal, warna, status. Preview `Rp per pertemuan` otomatis saat mengisi harga & jumlah pertemuan. + +### 10.6 Kehadiran +Pilih kelas → pilih pertemuan → daftar siswa dengan tombol status (segmented H/T/I/S/A) + tombol "Hadir Semua". Progress "12/12 terisi". Simpan. + +### 10.7 Pembayaran +Form: pilih siswa → pilih kelas (auto ambil harga) → **jumlah pertemuan dibayar** (stepper) → tampil kalkulasi prorata besar: `10 × Rp 200.000 = Rp 2.000.000` → metode (Cash/Transfer/QRIS segmented) → tanggal → upload bukti (opsional). List pembayaran: invoice mono, siswa, kelas, sesi, nominal, metode chip, status. + +### 10.8 Jurnal Aktivitas +Feed masonry/card: cover foto, judul, kelas, tanggal, tag, author, jumlah siswa. Tombol "+ Buat Jurnal": upload multi-foto (drag reorder), judul, deskripsi, kelas, sesi, tag, tandai siswa yang terlibat. + +### 10.9 Raport +Pilih kelas → pilih siswa → form aspek penilaian (slider/input 0–100): Logika, Computational Thinking, Problem Solving, Kreativitas, Kolaborasi → kesimpulan guru, grade otomatis, ringkasan kehadiran. Preview raport bertema (header gradient, tanda tangan guru). + +### 10.10 Placement Test +Form: siswa, tanggal, pemeriksa, skor kategori (bar input), level hasil otomatis dari rata-rata + rekomendasi kelas. Riwayat list. + +### 10.11 Pengaturan +Sumber lead (label + warna + aktif), daftar guru/user, info akademi (nama, logo, alamat, kontak), preferensi (mata uang, timezone). + +--- + +## 11. Motion + +| Aksi | Durasi | Easing | +|------|--------|--------| +| Hover kartu/tombol | 150ms | ease-out | +| Masuk halaman (fade+up 8px) | 250ms | cubic-bezier(.2,.8,.2,1) | +| Chart render | 400ms | ease-out | +| Drawer/sheet | 240ms | cubic-bezier(.2,.8,.2,1) | +| Konfeti saat siswa enroll 🎉 | 800ms | sekali, opsional | + +Hormati `prefers-reduced-motion`: matikan animasi non-esensial. + +--- + +## 12. Aksesibilitas + +- Kontras teks minimal **AA** (4.5:1 body, 3:1 besar). Jangan taruh teks putih di amber/kuning. +- Fokus terlihat di semua elemen interaktif (ring brand). +- Jangan sampaikan status **hanya** lewat warna — selalu ada label/ikon. +- Semua input punya `