kodingup.camp

Beranda/Various Library Mastery

Proyek Akhir Fase 3

Modul 13 dari 136 menit bacaDashboard Manajemen Tugas

Isi modul

    Tujuan

    Di proyek ini kamu menggabungkan semua library dari Fase 3 menjadi satu aplikasi utuh: Dashboard Manajemen Tugas. User bisa melihat daftar tugas dari API, menambah tugas lewat dialog dengan validasi, mencentang tugas selesai, menghapus tugas, memfilter dan mencari, melihat statistik progres, serta mengganti tema gelap/terang yang diingat setelah refresh. Hasil akhirnya di-deploy ke Vercel atau Netlify sehingga punya URL publik untuk portofolio.

    Setiap library punya peran yang jelas, sesuai prinsip yang kamu pelajari:

    Bagian Library Peran
    Data tugas dari server TanStack Query 5.104 Cache, loading/error, mutation, optimistic update
    Komunikasi HTTP axios 1.20 Instance terpusat, baseURL dari env, interceptor
    Filter, pencarian, tema Zustand 5.0 Client state + persist ke localStorage
    Form tambah tugas React Hook Form 7.90 + Zod 4.6 Validasi skema dengan pesan bahasa Indonesia
    Tampilan shadcn/ui + Tailwind CSS 4.3 Button, Input, Label, Dialog, Checkbox, Card
    Fondasi React 19.3 + Vite 8.3 Build dan dev server

    API yang dipakai adalah jsonplaceholder (/todos?userId=1, 20 tugas). Ingat sifatnya: POST/PATCH/DELETE membalas sukses tapi tidak menyimpan apa pun. Karena itu aplikasi ini memperbarui cache TanStack Query secara langsung (setQueryData dan optimistic update) dan mengatur staleTime: Infinity supaya refetch otomatis tidak "menghapus" perubahan user. Dengan backend sungguhan, kamu tinggal menambahkan invalidateQueries di onSettled.

    Fitur wajib

    1. Daftar tugas dari GET /todos?userId=1 dengan status memuat dan pesan error yang ramah, plus tombol "Coba lagi".
    2. Tambah tugas lewat Dialog shadcn berisi form React Hook Form + Zod: judul 3-80 karakter (di-trim) dan prioritas (rendah/sedang/tinggi). Pesan error berbahasa Indonesia. Dialog tertutup dan form direset setelah sukses.
    3. Centang selesai dengan optimistic update dan rollback kalau request gagal.
    4. Hapus tugas dengan optimistic update dan rollback.
    5. Filter status (semua/aktif/selesai) dan pencarian judul yang disimpan di Zustand; status filter dan tema bertahan setelah refresh (persist), sedangkan teks pencarian tidak.
    6. Statistik (total, selesai, persen progres) sebagai nilai turunan, bukan state terpisah.
    7. Tema gelap/terang memakai class dark di <html>.
    8. Konfigurasi lewat env: VITE_API_URL dan VITE_APP_NAME, tidak ada URL API yang ditulis langsung di komponen.
    9. Deploy ke Vercel atau Netlify dengan URL publik yang bisa dibuka.

    Langkah bertahap

    Langkah 1, Scaffold proyek.

    npm create vite@latest dashboard-tugas -- --template react
    cd dashboard-tugas
    npm install
    npm install tailwindcss @tailwindcss/vite
    

    Langkah 2, Alias @ dan Tailwind. Isi vite.config.js dan jsconfig.json seperti di modul shadcn/ui, lalu ganti seluruh isi src/index.css dengan @import "tailwindcss";. Hapus src/App.css dan import-nya.

    Langkah 3, shadcn/ui.

    npx shadcn@latest init -b radix
    npx shadcn@latest add button input label dialog checkbox card
    

    CLI otomatis menambahkan @custom-variant dark (&:is(.dark *)); ke src/index.css, jadi class dark di <html> langsung mengaktifkan tema gelap shadcn.

    Langkah 4, Library data, state, dan form.

    echo "legacy-peer-deps=true" > .npmrc   # solusi sementara ERESOLVE @hookform/resolvers (issue #882)
    npm install @tanstack/react-query axios zustand react-hook-form zod @hookform/resolvers
    npm install -D @tanstack/react-query-devtools
    

    Langkah 5, Environment variable. Buat .env (untuk lokal) dan .env.example (untuk dokumentasi, di-commit):

    VITE_API_URL=https://jsonplaceholder.typicode.com
    VITE_APP_NAME=Dashboard Tugas
    

    Langkah 6, Lapisan data. Tulis src/lib/api.js, src/lib/queryClient.js, src/features/tasks/taskApi.js, dan useTasks.js (lihat kode kerangka). Uji dulu dengan menampilkan JSON.stringify(data) sebelum membuat UI.

    Langkah 7, Client state. Tulis src/stores/useUiStore.js dengan persist + devtools. Cek di DevTools > Application > Local Storage bahwa key dashboard-ui muncul setelah kamu mengganti filter atau tema.

    Langkah 8, Form dan UI. Tulis taskSchema.js, TaskFormDialog.jsx, TaskToolbar.jsx, TaskStats.jsx, TaskList.jsx, lalu rakit di App.jsx dan main.jsx.

    Langkah 9, Uji manual. Jalankan npm run dev dan cek semua kriteria lulus. Lalu npm run build dan npm run preview untuk memastikan versi produksi juga jalan.

    Langkah 10, Deploy.

    Kode kerangka

    Kode di bawah sudah diuji: npm run build berhasil, dan di browser alur tambah (validasi "Judul minimal 3 karakter" muncul untuk input "ab"), centang, statistik, dan hapus berjalan dengan benar. Struktur foldernya:

    src/
    ├── components/ui/        ← dibuat oleh CLI shadcn
    ├── features/tasks/
    │   ├── taskApi.js
    │   ├── taskSchema.js
    │   ├── useTasks.js
    │   ├── TaskFormDialog.jsx
    │   ├── TaskList.jsx
    │   ├── TaskStats.jsx
    │   └── TaskToolbar.jsx
    ├── lib/
    │   ├── api.js
    │   ├── queryClient.js
    │   └── utils.js          ← dibuat oleh CLI shadcn
    ├── stores/useUiStore.js
    ├── App.jsx
    ├── main.jsx
    └── index.css
    
    // vite.config.js
    import { fileURLToPath, URL } from "node:url";
    import { defineConfig } from "vite";
    import react from "@vitejs/plugin-react";
    import tailwindcss from "@tailwindcss/vite";
    export default defineConfig({
      plugins: [react(), tailwindcss()],
      resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } },
    });
    
    // src/lib/api.js
    import axios from "axios";
    
    export const api = axios.create({
      baseURL: import.meta.env.VITE_API_URL,
      timeout: 10000,
    });
    
    api.interceptors.request.use((config) => {
      const token = localStorage.getItem("token");
      if (token) config.headers.Authorization = `Bearer ${token}`;
      return config;
    });
    
    api.interceptors.response.use(
      (res) => res,
      (error) => {
        if (axios.isAxiosError(error)) {
          const status = error.response?.status;
          if (error.code === "ECONNABORTED") error.message = "Server terlalu lama merespons.";
          else if (!error.response) error.message = "Tidak bisa terhubung ke server.";
          else if (status === 401) error.message = "Sesi berakhir, silakan login lagi.";
          else if (status === 404) error.message = "Data tidak ditemukan.";
          else if (status >= 500) error.message = "Server sedang bermasalah.";
        }
        return Promise.reject(error);
      }
    );
    
    // src/lib/queryClient.js
    import { QueryClient } from "@tanstack/react-query";
    
    export const queryClient = new QueryClient({
      defaultOptions: {
        queries: {
          // jsonplaceholder tidak menyimpan perubahan, jadi refetch akan menghapus
          // tugas yang kita tambah. Data dianggap segar selamanya selama sesi.
          staleTime: Infinity,
          refetchOnWindowFocus: false,
          retry: 1,
        },
      },
    });
    
    // src/stores/useUiStore.js
    import { create } from "zustand";
    import { devtools, persist } from "zustand/middleware";
    
    export const useUiStore = create(
      devtools(
        persist(
          (set) => ({
            status: "semua", // "semua" | "aktif" | "selesai"
            search: "",
            theme: "light",
            setStatus: (status) => set({ status }),
            setSearch: (search) => set({ search }),
            toggleTheme: () => set((s) => ({ theme: s.theme === "light" ? "dark" : "light" })),
          }),
          {
            name: "dashboard-ui",
            partialize: (s) => ({ status: s.status, theme: s.theme }), // search tidak disimpan
          }
        ),
        { name: "UiStore" }
      )
    );
    
    // src/features/tasks/taskSchema.js
    import { z } from "zod";
    
    export const PRIORITAS = ["rendah", "sedang", "tinggi"];
    
    export const taskSchema = z.object({
      title: z
        .string()
        .trim()
        .min(3, "Judul minimal 3 karakter")
        .max(80, "Judul maksimal 80 karakter"),
      priority: z.enum(PRIORITAS, { error: "Pilih prioritas yang tersedia" }),
    });
    
    // src/features/tasks/taskApi.js
    import { api } from "@/lib/api";
    
    export const taskKeys = {
      all: ["tasks"],
      list: () => ["tasks", "list"],
    };
    
    const USER_ID = 1;
    
    export async function fetchTasks(signal) {
      const { data } = await api.get("/todos", { params: { userId: USER_ID }, signal });
      // data asli tidak punya prioritas; beri nilai awal supaya bentuknya seragam
      return data.map((t) => ({ ...t, priority: "sedang" }));
    }
    
    export async function createTask(input) {
      const { data } = await api.post("/todos", { ...input, completed: false, userId: USER_ID });
      // jsonplaceholder selalu membalas id 201, jadi buat id unik sendiri
      return { ...data, id: Date.now() };
    }
    
    export async function updateTask({ id, ...perubahan }) {
      await api.patch(`/todos/${id}`, perubahan);
      return { id, ...perubahan };
    }
    
    export async function deleteTask(id) {
      await api.delete(`/todos/${id}`);
      return id;
    }
    
    // src/features/tasks/useTasks.js
    import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
    import { createTask, deleteTask, fetchTasks, taskKeys, updateTask } from "./taskApi";
    
    export function useTasks() {
      return useQuery({
        queryKey: taskKeys.list(),
        queryFn: ({ signal }) => fetchTasks(signal),
      });
    }
    
    export function useCreateTask() {
      const queryClient = useQueryClient();
      return useMutation({
        mutationFn: createTask,
        onSuccess: (baru) => {
          queryClient.setQueryData(taskKeys.list(), (lama = []) => [baru, ...lama]);
        },
      });
    }
    
    // pola optimistic update yang dipakai bersama oleh update dan delete
    function useOptimisticTaskMutation(mutationFn, ubahCache) {
      const queryClient = useQueryClient();
      return useMutation({
        mutationFn,
        onMutate: async (variabel) => {
          await queryClient.cancelQueries({ queryKey: taskKeys.list() });
          const sebelumnya = queryClient.getQueryData(taskKeys.list());
          queryClient.setQueryData(taskKeys.list(), (lama = []) => ubahCache(lama, variabel));
          return { sebelumnya };
        },
        onError: (_err, _variabel, hasil) => {
          queryClient.setQueryData(taskKeys.list(), hasil?.sebelumnya);
        },
      });
    }
    
    export function useUpdateTask() {
      return useOptimisticTaskMutation(updateTask, (lama, { id, ...perubahan }) =>
        lama.map((t) => (t.id === id ? { ...t, ...perubahan } : t))
      );
    }
    
    export function useDeleteTask() {
      return useOptimisticTaskMutation(deleteTask, (lama, id) => lama.filter((t) => t.id !== id));
    }
    
    // src/features/tasks/TaskFormDialog.jsx
    import { useState } from "react";
    import { useForm } from "react-hook-form";
    import { zodResolver } from "@hookform/resolvers/zod";
    import { Button } from "@/components/ui/button";
    import { Input } from "@/components/ui/input";
    import { Label } from "@/components/ui/label";
    import {
      Dialog,
      DialogContent,
      DialogDescription,
      DialogFooter,
      DialogHeader,
      DialogTitle,
      DialogTrigger,
    } from "@/components/ui/dialog";
    import { PRIORITAS, taskSchema } from "./taskSchema";
    import { useCreateTask } from "./useTasks";
    
    export function TaskFormDialog() {
      const [open, setOpen] = useState(false);
      const createTask = useCreateTask();
      const {
        register,
        handleSubmit,
        reset,
        setError,
        formState: { errors, isSubmitting },
      } = useForm({
        resolver: zodResolver(taskSchema),
        defaultValues: { title: "", priority: "sedang" },
      });
    
      async function onValid(data) {
        try {
          await createTask.mutateAsync(data);
          reset();
          setOpen(false);
        } catch (err) {
          setError("root.server", { message: err.message });
        }
      }
    
      return (
        <Dialog
          open={open}
          onOpenChange={(o) => {
            setOpen(o);
            if (!o) reset();
          }}
        >
          <DialogTrigger asChild>
            <Button>+ Tugas baru</Button>
          </DialogTrigger>
          <DialogContent>
            <form onSubmit={handleSubmit(onValid)} noValidate className="space-y-4">
              <DialogHeader>
                <DialogTitle>Tambah tugas</DialogTitle>
                <DialogDescription>Isi judul dan prioritas tugas.</DialogDescription>
              </DialogHeader>
    
              <div className="space-y-2">
                <Label htmlFor="title">Judul</Label>
                <Input id="title" aria-invalid={!!errors.title} {...register("title")} />
                {errors.title && <p className="text-sm text-destructive">{errors.title.message}</p>}
              </div>
    
              <div className="space-y-2">
                <Label htmlFor="priority">Prioritas</Label>
                <select
                  id="priority"
                  className="h-8 w-full rounded-lg border border-input bg-transparent px-2 text-sm"
                  {...register("priority")}
                >
                  {PRIORITAS.map((p) => (
                    <option key={p} value={p}>
                      {p}
                    </option>
                  ))}
                </select>
                {errors.priority && <p className="text-sm text-destructive">{errors.priority.message}</p>}
              </div>
    
              {errors.root?.server && (
                <p className="text-sm text-destructive">{errors.root.server.message}</p>
              )}
    
              <DialogFooter>
                <Button type="button" variant="outline" onClick={() => setOpen(false)}>
                  Batal
                </Button>
                <Button type="submit" disabled={isSubmitting}>
                  {isSubmitting ? "Menyimpan..." : "Simpan"}
                </Button>
              </DialogFooter>
            </form>
          </DialogContent>
        </Dialog>
      );
    }
    
    // src/features/tasks/TaskToolbar.jsx
    import { useShallow } from "zustand/react/shallow";
    import { Button } from "@/components/ui/button";
    import { Input } from "@/components/ui/input";
    import { useUiStore } from "@/stores/useUiStore";
    
    const STATUS = ["semua", "aktif", "selesai"];
    
    export function TaskToolbar() {
      const { status, search, setStatus, setSearch } = useUiStore(
        useShallow((s) => ({
          status: s.status,
          search: s.search,
          setStatus: s.setStatus,
          setSearch: s.setSearch,
        }))
      );
    
      return (
        <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
          <Input
            placeholder="Cari tugas..."
            value={search}
            onChange={(e) => setSearch(e.target.value)}
            className="sm:max-w-xs"
          />
          <div className="flex gap-1">
            {STATUS.map((st) => (
              <Button
                key={st}
                size="sm"
                variant={st === status ? "default" : "outline"}
                onClick={() => setStatus(st)}
              >
                {st}
              </Button>
            ))}
          </div>
        </div>
      );
    }
    
    // src/features/tasks/TaskStats.jsx
    import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
    
    export function TaskStats({ tasks }) {
      // nilai turunan: dihitung dari tasks, tidak disimpan di state
      const total = tasks.length;
      const selesai = tasks.filter((t) => t.completed).length;
      const persen = total === 0 ? 0 : Math.round((selesai / total) * 100);
    
      const kartu = [
        { label: "Total tugas", nilai: total },
        { label: "Selesai", nilai: selesai },
        { label: "Progres", nilai: `${persen}%` },
      ];
    
      return (
        <div className="grid gap-3 sm:grid-cols-3">
          {kartu.map((k) => (
            <Card key={k.label}>
              <CardHeader>
                <CardTitle className="text-sm text-muted-foreground">{k.label}</CardTitle>
              </CardHeader>
              <CardContent className="text-2xl font-bold">{k.nilai}</CardContent>
            </Card>
          ))}
        </div>
      );
    }
    
    // src/features/tasks/TaskList.jsx
    import { useMemo } from "react";
    import { Button } from "@/components/ui/button";
    import { Checkbox } from "@/components/ui/checkbox";
    import { useUiStore } from "@/stores/useUiStore";
    import { useDeleteTask, useUpdateTask } from "./useTasks";
    
    const warnaPrioritas = {
      rendah: "bg-slate-100 text-slate-700",
      sedang: "bg-amber-100 text-amber-800",
      tinggi: "bg-red-100 text-red-700",
    };
    
    export function TaskList({ tasks }) {
      const status = useUiStore((s) => s.status);
      const search = useUiStore((s) => s.search);
      const updateTask = useUpdateTask();
      const deleteTask = useDeleteTask();
    
      const terfilter = useMemo(() => {
        const q = search.trim().toLowerCase();
        return tasks
          .filter((t) => (status === "aktif" ? !t.completed : status === "selesai" ? t.completed : true))
          .filter((t) => t.title.toLowerCase().includes(q));
      }, [tasks, status, search]);
    
      if (terfilter.length === 0) {
        return <p className="py-8 text-center text-muted-foreground">Tidak ada tugas yang cocok.</p>;
      }
    
      return (
        <ul className="divide-y rounded-lg border">
          {terfilter.map((t) => (
            <li key={t.id} className="flex items-center gap-3 p-3">
              <Checkbox
                checked={t.completed}
                onCheckedChange={(v) => updateTask.mutate({ id: t.id, completed: v === true })}
                aria-label={`Tandai "${t.title}" selesai`}
              />
              <span className={`flex-1 ${t.completed ? "text-muted-foreground line-through" : ""}`}>
                {t.title}
              </span>
              <span className={`rounded px-2 py-0.5 text-xs ${warnaPrioritas[t.priority]}`}>
                {t.priority}
              </span>
              <Button variant="destructive" size="sm" onClick={() => deleteTask.mutate(t.id)}>
                Hapus
              </Button>
            </li>
          ))}
        </ul>
      );
    }
    
    // src/App.jsx
    import { useEffect } from "react";
    import { Button } from "@/components/ui/button";
    import { useUiStore } from "@/stores/useUiStore";
    import { TaskFormDialog } from "@/features/tasks/TaskFormDialog";
    import { TaskList } from "@/features/tasks/TaskList";
    import { TaskStats } from "@/features/tasks/TaskStats";
    import { TaskToolbar } from "@/features/tasks/TaskToolbar";
    import { useTasks } from "@/features/tasks/useTasks";
    
    export default function App() {
      const theme = useUiStore((s) => s.theme);
      const toggleTheme = useUiStore((s) => s.toggleTheme);
      const { data: tasks = [], isPending, isError, error, refetch } = useTasks();
    
      useEffect(() => {
        document.documentElement.classList.toggle("dark", theme === "dark");
      }, [theme]);
    
      return (
        <div className="min-h-svh bg-background text-foreground">
          <main className="mx-auto max-w-3xl space-y-6 p-4 sm:p-8">
            <header className="flex items-center justify-between gap-2">
              <h1 className="text-2xl font-bold">{import.meta.env.VITE_APP_NAME}</h1>
              <div className="flex gap-2">
                <Button variant="outline" onClick={toggleTheme}>
                  {theme === "dark" ? "Terang" : "Gelap"}
                </Button>
                <TaskFormDialog />
              </div>
            </header>
    
            {isPending && <p>Memuat tugas...</p>}
            {isError && (
              <div className="space-y-2">
                <p className="text-destructive">{error.message}</p>
                <Button variant="outline" onClick={() => refetch()}>Coba lagi</Button>
              </div>
            )}
            {!isPending && !isError && (
              <>
                <TaskStats tasks={tasks} />
                <TaskToolbar />
                <TaskList tasks={tasks} />
              </>
            )}
          </main>
        </div>
      );
    }
    
    // src/main.jsx
    import { StrictMode } from "react";
    import { createRoot } from "react-dom/client";
    import { QueryClientProvider } from "@tanstack/react-query";
    import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
    import { queryClient } from "@/lib/queryClient";
    import App from "./App.jsx";
    import "./index.css";
    
    createRoot(document.getElementById("root")).render(
      <StrictMode>
        <QueryClientProvider client={queryClient}>
          <App />
          <ReactQueryDevtools initialIsOpen={false} />
        </QueryClientProvider>
      </StrictMode>
    );
    
    # Hasil yang diharapkan setelah npm run dev lalu membuka http://localhost:5173
    # → Kartu statistik: Total tugas 20 | Selesai 11 | Progres 55%
    # → Tambah "Rapat tim mingguan" → muncul di paling atas, Total tugas 21
    # → Centang tugas → Selesai bertambah seketika (optimistic update)
    # → Pilih filter "selesai" + mode gelap, refresh → keduanya tetap
    

    Beberapa keputusan desain yang perlu kamu pahami (dan bisa kamu jelaskan saat wawancara):

    Kriteria lulus

    Pengembangan lanjutan

    1. Edit tugas: pakai Dialog yang sama untuk mengedit judul dan prioritas (reset dengan data tugas saat dialog dibuka, mutation useUpdateTask).
    2. Konfirmasi hapus: bungkus tombol Hapus dengan ConfirmDeleteDialog dari latihan modul shadcn/ui.
    3. Backend sungguhan: ganti jsonplaceholder dengan json-server lokal atau Supabase, ubah VITE_API_URL, kembalikan staleTime ke nilai wajar, lalu tambahkan onSettled: () => queryClient.invalidateQueries({ queryKey: taskKeys.all }).
    4. Tenggat waktu: tambahkan field deadline dengan validasi Zod "tidak boleh di masa lalu" dan urutkan daftar berdasarkan tenggat.
    5. Pagination atau infinite scroll: ambil semua 200 todos dengan useInfiniteQuery dan tombol "Muat lebih banyak".
    6. Notifikasi: tambahkan komponen toast (misalnya npx shadcn@latest add sonner) untuk pesan "Tugas ditambahkan" dan "Gagal menyimpan".
    7. Code splitting: build menampilkan peringatan chunk di atas 500 kB. Coba pisahkan Dialog dengan React.lazy dan bandingkan ukuran hasil build.

    Kuis pemahaman

    Pilih satu jawaban per soal. Penjelasan muncul setelah kamu menjawab.