Tech

AI Agent Dashboard Bagian 1: Setup, Layout & Navigasi

Part 1 — Fondasi dashboard AI agent dari nol. Next.js 14, Tailwind, shadcn/ui, dan arsitektur layout yang scalable.
13
1 bulan lalu
Zainul Fanani
AI Agent Dashboard Bagian 1: Setup, Layout & Navigasi
📅 28 Mar 2026🤍 0 👁 0 🔗 0

📎 Source:openclaw-sumopod — view on GitHub & star ⭐

🚀 Membangun AI Agent Dashboard — Tutorial Lengkap (Bagian 1)

Next.js 14 + Tailwind CSS + shadcn/ui + Recharts Dari nol sampai dashboard yang bisa dipake buat monitor AI agent kamu. Bahasa Indonesia, newbie-friendly, full code — tinggal copy-paste.


📦 PART 1: Setup & Foundation

Oke, sebelum kita mulai ngoding, kita perlu setup foundation dulu. Bayangin kayak bangun rumah — fondasi harus kuat dulu sebelum pasang atap.

1.1 Buat Project Next.js 14

Buka terminal, lalu jalankan:

bash
npx create-next-app@latest radit-dashboard --typescript --tailwind --app --src-dir --no-eslint

Nanti dia nanya beberapa hal, jawab seperti ini:

Tunggu sampai selesai, lalu masuk ke folder project:

bash
cd radit-dashboard

💡 Tips: Pastikan Node.js versi 18+ terinstall. Cek dengan node -v. Kalau belum, install pakai nvm install 18 dulu.

1.2 Install Dependencies

Kita butuh beberapa library tambahan:

bash
# shadcn/ui dependencies
npm install class-variance-authority clsx tailwind-merge lucide-react

# Recharts buat chart
npm install recharts

# shadcn/ui components (nanti kita install per component)
npx shadcn-ui@latest init

# Sonner untuk toast notification
npx shadcn-ui@latest add sonner
npx shadcn-ui@latest add card
npx shadcn-ui@latest add button
npx shadcn-ui@latest add badge
npx shadcn-ui@latest add input
npx shadcn-ui@latest add select
npx shadcn-ui@latest add table
npx shadcn-ui@latest add textarea
npx shadcn-ui@latest add dropdown-menu
npx shadcn-ui@latest add tooltip
npx shadcn-ui@latest add avatar
npx shadcn-ui@latest add separator
npx shadcn-ui@latest add scroll-area

⚠️ Pitfall: Kalau shadcn-ui command nggak kerja, coba npx shadcn@latest add ... (tanpa -ui). shadcn sempat ganti nama package-nya.

1.3 Konfigurasi Tailwind CSS

Buka tailwind.config.ts dan replace isinya:

typescript
// tailwind.config.ts
import type { Config } from "tailwindcss";

const config: Config = {
  darkMode: "class",
  content: [
    "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
    "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
    "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
  ],
  theme: {
    extend: {
      colors: {
        // Warna utama — hijau khas dashboard agent
        primary: {
          50: "#f0fdf4",
          100: "#dcfce7",
          200: "#bbf7d0",
          300: "#86efac",
          400: "#4ade80",
          500: "#22c55e",
          600: "#16a34a",
          700: "#15803d",
          800: "#166534",
          900: "#14532d",
          950: "#052e16",
          DEFAULT: "#22c55e",
        },
        // Accent — gold premium
        accent: {
          50: "#fffbeb",
          100: "#fef3c7",
          200: "#fde68a",
          300: "#fcd34d",
          400: "#fbbf24",
          500: "#f59e0b",
          600: "#d97706",
          700: "#b45309",
          800: "#92400e",
          900: "#78350f",
          DEFAULT: "#fbbf24",
        },
        // Sidebar dark
        sidebar: {
          DEFAULT: "#0f172a",
          hover: "#1e293b",
          active: "#334155",
        },
      },
      fontFamily: {
        sans: ["Inter", "system-ui", "sans-serif"],
        mono: ["JetBrains Mono", "Fira Code", "monospace"],
      },
    },
  },
  plugins: [require("tailwindcss-animate")],
};

export default config;

💡 Tips: Warna hijau = fresh & techy, gold = premium & trustworthy. Kombinasi ini enak dilihat di dashboard yang mostly gelap.

1.4 Global Styles

Buka src/app/globals.css dan replace:

css
/* src/app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  :root {
    --background: 248 250 252;       /* slate-50 */
    --foreground: 15 23 42;           /* slate-900 */
    --card: 255 255 255;
    --card-foreground: 15 23 42;
    --popover: 255 255 255;
    --popover-foreground: 15 23 42;
    --primary: 34 197 94;             /* green-500 */
    --primary-foreground: 255 255 255;
    --secondary: 241 245 249;         /* slate-100 */
    --secondary-foreground: 15 23 42;
    --muted: 241 245 249;
    --muted-foreground: 100 116 139;  /* slate-500 */
    --accent: 251 191 36;             /* amber-400 */
    --accent-foreground: 15 23 42;
    --destructive: 239 68 68;
    --destructive-foreground: 255 255 255;
    --border: 226 232 240;            /* slate-200 */
    --input: 226 232 240;
    --ring: 34 197 94;
    --radius: 0.75rem;
  }
}

@layer base {
  * {
    @apply border-border;
  }
  body {
    @apply bg-background text-foreground font-sans antialiased;
  }
}

/* Custom scrollbar */
::-webkit-scrollbar {
  width: 6px;
  height: 6px;
}

::-webkit-scrollbar-track {
  background: transparent;
}

::-webkit-scrollbar-thumb {
  @apply bg-slate-300 rounded-full;
}

::-webkit-scrollbar-thumb:hover {
  @apply bg-slate-400;
}

/* Animasi gauge SVG */
@keyframes gauge-fill {
  from {
    stroke-dashoffset: var(--gauge-circumference);
  }
  to {
    stroke-dashoffset: var(--gauge-offset);
  }
}

.gauge-animated {
  animation: gauge-fill 1.5s ease-out forwards;
}

1.5 Utility Functions

Buat file src/lib/utils.ts:

typescript
// src/lib/utils.ts
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";

/**
 * Merge Tailwind classes tanpa konflik
 * shadcn/ui standard utility
 */
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

/**
 * Format angka dengan ribuan separator
 * 12345 → "12.345"
 */
export function formatNumber(num: number): string {
  return new Intl.NumberFormat("id-ID").format(num);
}

/**
 * Format bytes ke KB/MB/GB
 */
export function formatBytes(bytes: number): string {
  if (bytes === 0) return "0 B";
  const k = 1024;
  const sizes = ["B", "KB", "MB", "GB", "TB"];
  const i = Math.floor(Math.log(bytes) / Math.log(k));
  return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}

/**
 * Format durasi detik ke jam:menit:detik
 */
export function formatDuration(seconds: number): string {
  const h = Math.floor(seconds / 3600);
  const m = Math.floor((seconds % 3600) / 60);
  const s = Math.floor(seconds % 60);
  if (h > 0) return `${h}j ${m}m`;
  if (m > 0) return `${m}m ${s}d`;
  return `${s}d`;
}

/**
 * Format tanggal ke format Indo
 */
export function formatDate(date: Date | string): string {
  return new Intl.DateTimeFormat("id-ID", {
    day: "numeric",
    month: "short",
    year: "numeric",
    hour: "2-digit",
    minute: "2-digit",
  }).format(new Date(date));
}

1.6 Folder Structure

Buat semua folder dan file kosong dulu biar gampang navigasi:

bash
# Components
mkdir -p src/components/layout
mkdir -p src/components/dashboard
mkdir -p src/components/briefing
mkdir -p src/components/system
mkdir -p src/components/sessions
mkdir -p src/components/skills
mkdir -p src/components/ui        # shadcn/ui taruh sini (auto)

# Library
mkdir -p src/lib

# API routes
mkdir -p src/app/api/status
mkdir -p src/app/api/briefing
mkdir -p src/app/api/system
mkdir -p src/app/api/sessions
mkdir -p src/app/api/skills

# Pages
mkdir -p src/app/briefing
mkdir -p src/app/system
mkdir -p src/app/sessions
mkdir -p src/app/skills
mkdir -p src/app/logs
mkdir -p src/app/schedule
mkdir -p src/app/models
mkdir -p src/app/settings

Struktur folder final:

text
src/
├── app/
│   ├── layout.tsx          ← Root layout (import Shell)
│   ├── page.tsx            ← Dashboard home
│   ├── globals.css
│   ├── briefing/page.tsx   ← Morning briefing
│   ├── system/page.tsx     ← System monitor
│   ├── sessions/page.tsx   ← Session manager
│   ├── skills/page.tsx     ← Skills hub
│   ├── logs/page.tsx
│   ├── schedule/page.tsx
│   ├── models/page.tsx
│   ├── settings/page.tsx
│   └── api/
│       ├── status/route.ts
│       ├── briefing/route.ts
│       ├── system/route.ts
│       ├── sessions/route.ts
│       └── skills/route.ts
├── components/
│   ├── ui/                 ← shadcn/ui (auto-generated)
│   ├── layout/
│   │   ├── sidebar.tsx
│   │   ├── header.tsx
│   │   └── shell.tsx
│   ├── dashboard/
│   │   ├── stats-grid.tsx
│   │   ├── usage-chart.tsx
│   │   ├── activity-feed.tsx
│   │   └── real-time-clock.tsx
│   ├── briefing/
│   │   └── briefing-card.tsx
│   ├── system/
│   │   ├── gauge.tsx
│   │   └── process-table.tsx
│   ├── sessions/
│   │   ├── session-table.tsx
│   │   └── session-chart.tsx
│   └── skills/
│       ├── skill-card.tsx
│       ├── skill-editor.tsx
│       └── skill-audit.tsx
└── lib/
    └── utils.ts

💡 Tips: Rapihin folder dari awal. Trust me, pas project udah gede, structure yang rapi itu nyelamatkan nyawa.

1.7 Architecture Overview

Ini gambaran besar arsitektur dashboard kita:

subgraph Client Client Browser
subgraph Client Client Browser

Penjelasan singkat:

  • Client = Browser user yang render React components
  • Next.js = Server-side rendering + API routes sebagai proxy ke backend
  • External = Data asli dari AI agent, sistem, dan Gemini API

⚠️ Pitfall: Jangan taruh API key di client-side code! Semua yang butuh secret key harus lewat API route (src/app/api/), bukan langsung di component.


Part 1 selesai! Foundation udah siap. Lanjut ke Part 2 — kita bangun layout & navigasi.


🏗️ PART 2: Layout & Navigation

Nah, sekarang kita bangun "kerangka" dashboard — sidebar, header, dan shell yang bakal nampung semua page. Ini kayak pasang dinding & pintu rumah.

2.1 Sidebar Component

Buat file src/components/layout/sidebar.tsx:

tsx
// src/components/layout/sidebar.tsx
"use client";

import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import {
  Home,
  Mail,
  Monitor,
  MessageSquare,
  Zap,
  Calendar,
  FileText,
  Brain,
  Settings,
  ChevronLeft,
  Bot,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip";

// Daftar navigasi sidebar
const navItems = [
  { href: "/", label: "Home", icon: Home },
  { href: "/briefing", label: "Briefing", icon: Mail },
  { href: "/system", label: "System", icon: Monitor },
  { href: "/sessions", label: "Sessions", icon: MessageSquare },
  { href: "/skills", label: "Skills", icon: Zap },
  { href: "/schedule", label: "Schedule", icon: Calendar },
  { href: "/logs", label: "Logs", icon: FileText },
  { href: "/models", label: "Models", icon: Brain },
  { href: "/settings", label: "Settings", icon: Settings },
];

interface SidebarProps {
  collapsed: boolean;
  onToggle: () => void;
  mobileOpen: boolean;
  onMobileClose: () => void;
}

export function Sidebar({
  collapsed,
  onToggle,
  mobileOpen,
  onMobileClose,
}: SidebarProps) {
  const pathname = usePathname();

  // Cek apakah route aktif (termasuk nested routes)
  const isActive = (href: string) => {
    if (href === "/") return pathname === "/";
    return pathname.startsWith(href);
  };

  const sidebarContent = (
    <div className="flex flex-col h-full bg-sidebar text-white">
      {/* Logo section */}
      <div className="flex items-center gap-3 px-4 h-16 border-b border-slate-700">
        <div className="flex items-center justify-center w-9 h-9 rounded-lg bg-primary text-white font-bold text-lg shrink-0">
          <Bot size={22} />
        </div>
        {/* Text hidden kalau sidebar collapsed (desktop) */}
        {!collapsed && (
          <div className="flex flex-col overflow-hidden">
            <span className="text-base font-bold tracking-tight whitespace-nowrap">
              Radit Dashboard
            </span>
            <span className="text-[10px] text-slate-400 whitespace-nowrap">
              AI Agent Monitor
            </span>
          </div>
        )}
      </div>

      {/* Navigation items */}
      <ScrollArea className="flex-1 py-3">
        <nav className="space-y-1 px-3">
          {navItems.map((item) => {
            const Icon = item.icon;
            const active = isActive(item.href);

            // Kalau collapsed, tampilkan tooltip
            const linkContent = (
              <Link
                href={item.href}
                onClick={onMobileClose}
                className={cn(
                  "flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200",
                  "text-sm font-medium",
                  active
                    ? "bg-primary text-white shadow-lg shadow-primary/20"
                    : "text-slate-300 hover:bg-sidebar-hover hover:text-white",
                  collapsed && "justify-center px-2"
                )}
              >
                <Icon size={20} className="shrink-0" />
                {!collapsed && <span>{item.label}</span>}
                {/* Active indicator dot */}
                {active && !collapsed && (
                  <span className="ml-auto w-1.5 h-1.5 rounded-full bg-white" />
                )}
              </Link>
            );

            // Desktop collapsed mode: wrap pake tooltip
            if (collapsed) {
              return (
                <TooltipProvider key={item.href} delayDuration={0}>
                  <Tooltip>
                    <TooltipTrigger asChild>{linkContent}</TooltipTrigger>
                    <TooltipContent side="right" className="font-medium">
                      {item.label}
                    </TooltipContent>
                  </Tooltip>
                </TooltipProvider>
              );
            }

            return <div key={item.href}>{linkContent}</div>;
          })}
        </nav>
      </ScrollArea>

      {/* Collapse toggle (desktop only) */}
      <div className="hidden lg:flex items-center justify-center p-3 border-t border-slate-700">
        <Button
          variant="ghost"
          size="sm"
          onClick={onToggle}
          className="text-slate-400 hover:text-white hover:bg-sidebar-hover w-full"
        >
          <ChevronLeft
            size={18}
            className={cn(
              "transition-transform duration-300",
              collapsed && "rotate-180"
            )}
          />
          {!collapsed && <span className="ml-2 text-xs">Collapse</span>}
        </Button>
      </div>
    </div>
  );

  return (
    <>
      {/* ====== MOBILE: Overlay sidebar ====== */}
      {mobileOpen && (
        <div className="lg:hidden fixed inset-0 z-50">
          {/* Backdrop */}
          <div
            className="absolute inset-0 bg-black/50 backdrop-blur-sm"
            onClick={onMobileClose}
          />
          {/* Sidebar panel */}
          <div className="relative w-64 h-full shadow-2xl animate-in slide-in-from-left duration-200">
            {sidebarContent}
          </div>
        </div>
      )}

      {/* ====== DESKTOP: Fixed sidebar ====== */}
      <aside
        className={cn(
          "hidden lg:block fixed left-0 top-0 h-full z-40 transition-all duration-300 border-r border-slate-800",
          collapsed ? "w-[68px]" : "w-64"
        )}
      >
        {sidebarContent}
      </aside>
    </>
  );
}

💡 Tips:usePathname() dari Next.js itu cara paling gampang detect active route. Lebih simpel daripada bikin custom router logic.

⚠️ Pitfall: Jangan lupa "use client" di atas setiap component yang pake hooks (useState, useEffect, usePathname). Lupa = error hydration.

2.2 Header Component

Buat src/components/layout/header.tsx:

tsx
// src/components/layout/header.tsx
"use client";

import { useState, useEffect } from "react";
import { usePathname } from "next/navigation";
import { Menu, Bell, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";

// Mapping route ke judul halaman
const pageTitles: Record<string, string> = {
  "/": "Dashboard",
  "/briefing": "Morning Briefing",
  "/system": "System Monitor",
  "/sessions": "Sessions",
  "/skills": "Skills Hub",
  "/schedule": "Schedule",
  "/logs": "Activity Logs",
  "/models": "Models",
  "/settings": "Settings",
};

interface HeaderProps {
  onMobileMenuClick: () => void;
  sidebarCollapsed: boolean;
}

export function Header({
  onMobileMenuClick,
  sidebarCollapsed,
}: HeaderProps) {
  const pathname = usePathname();
  const [currentTime, setCurrentTime] = useState(new Date());
  const [searchOpen, setSearchOpen] = useState(false);

  // Update jam setiap detik
  useEffect(() => {
    const timer = setInterval(() => setCurrentTime(new Date()), 1000);
    return () => clearInterval(timer);
  }, []);

  // Ambil judul halaman dari pathname
  const pageTitle = pageTitles[pathname] || "Dashboard";

  // Format jam Indonesia (WITA)
  const timeString = currentTime.toLocaleTimeString("id-ID", {
    hour: "2-digit",
    minute: "2-digit",
    second: "2-digit",
    timeZone: "Asia/Makassar",
  });

  const dateString = currentTime.toLocaleDateString("id-ID", {
    weekday: "long",
    day: "numeric",
    month: "long",
    year: "numeric",
    timeZone: "Asia/Makassar",
  });

  return (
    <header
      className={cn(
        "sticky top-0 z-30 h-16 bg-white/80 backdrop-blur-md border-b border-slate-200",
        "flex items-center justify-between px-4 md:px-6",
        "transition-all duration-300",
        sidebarCollapsed ? "lg:pl-[84px]" : "lg:pl-[280px]"
      )}
    >
      {/* Kiri: Hamburger + Page title */}
      <div className="flex items-center gap-3">
        {/* Hamburger menu (mobile only) */}
        <Button
          variant="ghost"
          size="icon"
          className="lg:hidden"
          onClick={onMobileMenuClick}
        >
          <Menu size={22} />
        </Button>

        <div>
          <h1 className="text-lg md:text-xl font-bold text-slate-900">
            {pageTitle}
          </h1>
          <p className="text-xs text-slate-500 hidden sm:block">
            {dateString}
          </p>
        </div>
      </div>

      {/* Kanan: Search, Clock, Notifications, Avatar */}
      <div className="flex items-center gap-2 md:gap-4">
        {/* Search bar (desktop) */}
        {searchOpen ? (
          <div className="hidden md:flex items-center">
            <Input
              placeholder="Cari sesuatu..."
              className="w-56 h-9"
              autoFocus
              onBlur={() => setSearchOpen(false)}
            />
          </div>
        ) : (
          <Button
            variant="ghost"
            size="icon"
            className="hidden md:flex"
            onClick={() => setSearchOpen(true)}
          >
            <Search size={18} className="text-slate-500" />
          </Button>
        )}

        {/* Jam real-time */}
        <div className="hidden sm:flex flex-col items-end">
          <span className="text-sm font-mono font-bold text-slate-700">
            {timeString}
          </span>
          <span className="text-[10px] text-slate-400">WITA</span>
        </div>

        {/* Notification bell */}
        <Button variant="ghost" size="icon" className="relative">
          <Bell size={18} className="text-slate-500" />
          {/* Badge notification */}
          <Badge className="absolute -top-1 -right-1 h-4 w-4 p-0 flex items-center justify-center text-[10px] bg-red-500 border-0">
            3
          </Badge>
        </Button>

        {/* User avatar */}
        <Avatar className="h-8 w-8">
          <AvatarImage src="/avatar.png" alt="User" />
          <AvatarFallback className="bg-primary text-white text-xs font-bold">
            RF
          </AvatarFallback>
        </Avatar>
      </div>
    </header>
  );
}

// Helper cn (import dari utils)
import { cn } from "@/lib/utils";

⚠️ Pitfall: Header padding kudu sync sama sidebar width. Kalau sidebar w-64, header padding harus lg:pl-[280px] (256px + 24px gap). Nggak sync = content ketutupan sidebar.

2.3 Shell Component (Layout Wrapper)

Buat src/components/layout/shell.tsx — ini wrapper utama yang nge-wrap sidebar + header + content:

tsx
// src/components/layout/shell.tsx
"use client";

import { useState } from "react";
import { cn } from "@/lib/utils";
import { Sidebar } from "./sidebar";
import { Header } from "./header";

interface ShellProps {
  children: React.ReactNode;
}

export function Shell({ children }: ShellProps) {
  const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
  const [mobileOpen, setMobileOpen] = useState(false);

  return (
    <div className="min-h-screen bg-slate-50">
      {/* Sidebar */}
      <Sidebar
        collapsed={sidebarCollapsed}
        onToggle={() => setSidebarCollapsed(!sidebarCollapsed)}
        mobileOpen={mobileOpen}
        onMobileClose={() => setMobileOpen(false)}
      />

      {/* Header */}
      <Header
        onMobileMenuClick={() => setMobileOpen(true)}
        sidebarCollapsed={sidebarCollapsed}
      />

      {/* Main content area */}
      <main
        className={cn(
          "p-4 md:p-6 transition-all duration-300",
          sidebarCollapsed ? "lg:ml-[84px]" : "lg:ml-[272px]"
        )}
      >
        {children}
      </main>
    </div>
  );
}

2.4 Update Root Layout

Replace src/app/layout.tsx:

tsx
// src/app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Shell } from "@/components/layout/shell";
import { Toaster } from "sonner";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Radit Dashboard — AI Agent Monitor",
  description: "Dashboard monitoring untuk AI agent system",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="id">
      <body className={inter.className}>
        {/* Toast notification provider */}
        <Toaster
          position="bottom-right"
          richColors
          closeButton
          toastOptions={{
            duration: 4000,
          }}
        />
        {/* Main layout shell */}
        <Shell>{children}</Shell>
      </body>
    </html>
  );
}

2.5 Component Hierarchy Diagram

RootLayoutRootLayoutbrapplayouttsx
RootLayoutRootLayoutbrapplayouttsx

2.6 Navigation State Diagram

stateDiagramv2
stateDiagramv2

💡 Tips: Desktop sidebar collapsed itu cuma 68px — pas banget buat ikon aja. Di mode ini, tooltip muncul on-hover buat kasih tau label-nya apa.

⚠️ Pitfall: Di mobile, jangan lupa close sidebar pas navigasi. User klik nav → sidebar tutup otomatis → dia langsung lihat halaman baru. Nggak enak kalau sidebar numpuk di atas content.


Part 2 selesai! Layout & navigasi sudah jadi. Lanjut ke Part 3 — Dashboard Home.


Ada Pertanyaan? Yuk Ngobrol!

Butuh bantuan setup OpenClaw, konsultasi IT, atau mau diskusi project engineering? Book a call langsung — gratis.

Book a Call — Gratis

via Cal.com • WITA (UTC+8)

📬 Subscribe Newsletter

Free

Dapat alert setiap ada artikel baru langsung ke inbox kamu. Free, no spam. 🚀

👥 Join 0+ engineers & tech enthusiasts

F

Zainul Fanani

Founder, Radian Group. Engineering & tech enthusiast.

💬 Komentar

Catatan Fanani

Ngutak-ngatik teknologi, nulis pengalaman.

Perusahaan

  • CV Radian Fokus Mandiri — Balikpapan
  • PT UNO Solusi Teknik — Balikpapan
  • PT Reka Formasi Elektrika — Jakarta
  • PT Raya Fokus Solusi — Sidoarjo
© 2026 Catatan Fanani. All rights reserved.