Compare commits

...

7 Commits

Author SHA1 Message Date
42dcbcfb22 fix-admin-menu-desa-berita 2026-02-25 16:25:59 +08:00
22de1aa1f3 fix-admin-menu-desa-potensi 2026-02-25 15:41:01 +08:00
b1d28a8322 fix-admin-menu-desa-profile 2026-02-25 15:25:51 +08:00
b86a3a85c3 fix: force default light mode for public pages and admin
- Set defaultColorScheme='light' in root MantineProvider
- Change darkModeStore default from system preference to false (light)
- Add MantineProvider with light theme to darmasaba/layout.tsx
- Remove dark mode dependency from ModuleView component
- Prevent system color scheme from affecting initial page load

This ensures consistent light mode on first visit for both
public pages and admin panel, regardless of OS settings.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-02-25 10:45:27 +08:00
fd63bb0fd4 feat: implement dark mode support & fix Prisma schema validation
- Add dark mode toggle component in admin header
- Integrate dark mode store across admin layout and components
- Add unified typography and surface components for consistent theming
- Implement smooth transitions for dark/light mode switching
- Fix Prisma schema: remove @default(null) from DateTime? fields
- Update form validation for inovasi, lingkungan, and pendidikan modules
- Add form validation and improve UX across multiple admin pages

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-02-25 10:41:48 +08:00
f0558aa0d0 feat: implement dark mode support for admin layout and components
- Add dark mode toggle component in admin header
- Integrate dark mode store across admin layout and child components
- Update header, judulList, and judulListTab components with theme tokens
- Add unified typography components for consistent theming
- Implement smooth transitions for dark/light mode switching
- Add mounted state to prevent hydration mismatches
- Style navbar with dark mode aware colors and hover states
- Update button styles with gradient effects for both themes

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-02-23 10:48:00 +08:00
8132609ccb feat: add form validation for inovasi, lingkungan, and pendidikan modules
- Added isFormValid() and isHtmlEmpty() helper functions for form validation
- Disabled submit buttons when required fields are empty across multiple admin and public pages
- Applied consistent validation pattern for creating and editing records
- Commented out WhatsApp OTP sending in login route for debugging/testing
- Fixed path in NavbarMainMenu tooltip action
2026-02-20 15:08:41 +08:00
93 changed files with 3719 additions and 251 deletions

169
darkMode.md Normal file
View File

@@ -0,0 +1,169 @@
# 🌙 Dark Mode Design Specification
## Admin Darmasaba Dashboard & CMS
Dokumen ini mendefinisikan standar **Dark Mode UI** agar:
- nyaman di mata
- konsisten
- tidak flat
- tetap profesional untuk aplikasi pemerintahan
---
## 🎨 Color Palette (Dark Mode)
### Background Layers
| Layer | Token | Warna | Fungsi |
|------|------|------|------|
| Base | `--bg-base` | `#0B1220` | Background utama aplikasi |
| App | `--bg-app` | `#0F172A` | Area kerja utama |
| Card | `--bg-card` | `#162235` | Card / container |
| Surface | `--bg-surface` | `#1E2A3D` | Table header, tab, input |
---
### Border & Divider
| Token | Warna | Catatan |
|-----|------|--------|
| `--border-default` | `#2A3A52` | Border utama |
| `--border-soft` | `#22314A` | Divider halus |
> ❗ Hindari border terlalu tipis (`opacity < 20%`)
---
### Text Colors
| Jenis | Token | Warna |
|-----|------|------|
| Primary | `--text-primary` | `#E5E7EB` |
| Secondary | `--text-secondary` | `#9CA3AF` |
| Muted | `--text-muted` | `#6B7280` |
| Inverse | `--text-inverse` | `#020617` |
---
### Accent & Action
| Fungsi | Warna |
|------|------|
| Primary Action | `#3B82F6` |
| Hover | `#2563EB` |
| Active | `#1D4ED8` |
| Link | `#60A5FA` |
---
### Status Colors
| Status | Warna |
|------|------|
| Success | `#22C55E` |
| Warning | `#FACC15` |
| Error | `#EF4444` |
| Info | `#38BDF8` |
---
## 🧱 Layout Rules
### Sidebar
- Background: `--bg-app`
- Active menu:
- Background: `rgba(59,130,246,0.15)`
- Text: Primary
- Indicator: kiri (23px accent bar)
- Hover:
- Background: `rgba(255,255,255,0.04)`
---
### Header / Topbar
- Background: `linear-gradient(#0F172A → #0B1220)`
- Border bawah wajib (`--border-soft`)
- Icon:
- Default: muted
- Hover: primary
---
## 📦 Card & Section
### Card
- Background: `--bg-card`
- Border: `--border-default`
- Radius: 1216px
- Jangan pakai shadow hitam
### Section Header
- Font weight lebih besar
- Text: primary
- Spacing jelas dari konten
---
## 📊 Table (Dark Mode Friendly)
### Table Header
- Background: `--bg-surface`
- Text: secondary
- Font weight: medium
### Table Row
- Default: transparent
- Hover:
- Background: `rgba(255,255,255,0.03)`
- Divider antar row wajib terlihat
### Link di Table
- Warna link **lebih terang dari text**
- Hover underline
---
## 🔘 Button Rules
### Primary Button
- Background: Primary Action
- Text: Inverse
- Hover: darker shade
### Secondary Button
- Background: transparent
- Border: `--border-default`
- Text: primary
### Icon Button
- Default: muted
- Hover: primary + bg soft
---
## 🧭 Tab Navigation
- Inactive:
- Text: muted
- Active:
- Background: `rgba(59,130,246,0.15)`
- Text: primary
- Icon ikut berubah
---
## 🌗 Dark vs Light Mode Rule
- Layout, spacing, typography **HARUS SAMA**
- Yang boleh beda:
- warna
- border intensity
- background layer
> ❌ Jangan ganti struktur UI antara dark & light
---
## ✅ Dark Mode Checklist
- [ ] Kontras teks terbaca
- [ ] Active state jelas
- [ ] Hover terasa hidup
- [ ] Tidak flat
- [ ] Tidak silau
---
Dokumen ini adalah **single source of truth** untuk Dark Mode.

View File

@@ -0,0 +1,170 @@
/*
Warnings:
- You are about to alter the column `nama` on the `KategoriPotensi` table. The data in that column could be lost. The data in that column will be cast from `Text` to `VarChar(100)`.
- You are about to alter the column `name` on the `PotensiDesa` table. The data in that column could be lost. The data in that column will be cast from `Text` to `VarChar(255)`.
- You are about to alter the column `kategoriId` on the `PotensiDesa` table. The data in that column could be lost. The data in that column will be cast from `Text` to `VarChar(36)`.
- A unique constraint covering the columns `[nama]` on the table `KategoriPotensi` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[name]` on the table `PotensiDesa` will be added. If there are existing duplicate values, this will fail.
- Made the column `kategoriId` on table `PotensiDesa` required. This step will fail if there are existing NULL values in that column.
*/
-- DropForeignKey
ALTER TABLE "DataPerpustakaan" DROP CONSTRAINT "DataPerpustakaan_imageId_fkey";
-- DropForeignKey
ALTER TABLE "DesaDigital" DROP CONSTRAINT "DesaDigital_imageId_fkey";
-- DropForeignKey
ALTER TABLE "InfoTekno" DROP CONSTRAINT "InfoTekno_imageId_fkey";
-- DropForeignKey
ALTER TABLE "KegiatanDesa" DROP CONSTRAINT "KegiatanDesa_imageId_fkey";
-- DropForeignKey
ALTER TABLE "PengaduanMasyarakat" DROP CONSTRAINT "PengaduanMasyarakat_imageId_fkey";
-- DropForeignKey
ALTER TABLE "PotensiDesa" DROP CONSTRAINT "PotensiDesa_kategoriId_fkey";
-- DropForeignKey
ALTER TABLE "ProfileDesaImage" DROP CONSTRAINT "ProfileDesaImage_imageId_fkey";
-- AlterTable
ALTER TABLE "CaraMemperolehInformasi" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "CaraMemperolehSalinanInformasi" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "DaftarInformasiPublik" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "DasarHukumPPID" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "DataPerpustakaan" ALTER COLUMN "imageId" DROP NOT NULL;
-- AlterTable
ALTER TABLE "DesaDigital" ALTER COLUMN "imageId" DROP NOT NULL;
-- AlterTable
ALTER TABLE "FormulirPermohonanKeberatan" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "InfoTekno" ALTER COLUMN "imageId" DROP NOT NULL;
-- AlterTable
ALTER TABLE "JenisInformasiDiminta" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "JenisKelaminResponden" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "KategoriPotensi" ALTER COLUMN "nama" SET DATA TYPE VARCHAR(100),
ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "KategoriPrestasiDesa" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "KegiatanDesa" ALTER COLUMN "imageId" DROP NOT NULL;
-- AlterTable
ALTER TABLE "LambangDesa" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "MaskotDesa" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "PegawaiPPID" ADD COLUMN "deletedAt" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "PengaduanMasyarakat" ALTER COLUMN "imageId" DROP NOT NULL;
-- AlterTable
ALTER TABLE "PermohonanInformasiPublik" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "PilihanRatingResponden" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "PosisiOrganisasiPPID" ADD COLUMN "deletedAt" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "PotensiDesa" ALTER COLUMN "name" SET DATA TYPE VARCHAR(255),
ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT,
ALTER COLUMN "kategoriId" SET NOT NULL,
ALTER COLUMN "kategoriId" SET DATA TYPE VARCHAR(36);
-- AlterTable
ALTER TABLE "PrestasiDesa" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "ProfileDesaImage" ALTER COLUMN "imageId" DROP NOT NULL;
-- AlterTable
ALTER TABLE "ProfilePPID" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "Responden" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "SejarahDesa" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "UmurResponden" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "VisiMisiDesa" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "VisiMisiPPID" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- CreateIndex
CREATE UNIQUE INDEX "KategoriPotensi_nama_key" ON "KategoriPotensi"("nama");
-- CreateIndex
CREATE UNIQUE INDEX "PotensiDesa_name_key" ON "PotensiDesa"("name");
-- AddForeignKey
ALTER TABLE "ProfileDesaImage" ADD CONSTRAINT "ProfileDesaImage_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PotensiDesa" ADD CONSTRAINT "PotensiDesa_kategoriId_fkey" FOREIGN KEY ("kategoriId") REFERENCES "KategoriPotensi"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DesaDigital" ADD CONSTRAINT "DesaDigital_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "InfoTekno" ADD CONSTRAINT "InfoTekno_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PengaduanMasyarakat" ADD CONSTRAINT "PengaduanMasyarakat_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "KegiatanDesa" ADD CONSTRAINT "KegiatanDesa_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DataPerpustakaan" ADD CONSTRAINT "DataPerpustakaan_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
provider = "postgresql"

View File

@@ -236,7 +236,7 @@ model PrestasiDesa {
imageId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
}
@@ -245,7 +245,7 @@ model KategoriPrestasiDesa {
name String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
PrestasiDesa PrestasiDesa[]
}
@@ -263,7 +263,7 @@ model Responden {
kelompokUmurId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
}
@@ -272,7 +272,7 @@ model JenisKelaminResponden {
name String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
Responden Responden[]
}
@@ -282,7 +282,7 @@ model PilihanRatingResponden {
name String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
Responden Responden[]
}
@@ -292,7 +292,7 @@ model UmurResponden {
name String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
Responden Responden[]
}
@@ -326,6 +326,7 @@ model PosisiOrganisasiPPID {
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
parent PosisiOrganisasiPPID? @relation("Parent", fields: [parentId], references: [id])
children PosisiOrganisasiPPID[] @relation("Parent")
StrukturOrganisasiPPID StrukturOrganisasiPPID[]
@@ -345,6 +346,7 @@ model PegawaiPPID {
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
posisi PosisiOrganisasiPPID @relation(fields: [posisiId], references: [id])
strukturOrganisasi StrukturPPID[] // Relasi balik
StrukturOrganisasiPPID StrukturOrganisasiPPID[]
@@ -370,7 +372,7 @@ model VisiMisiPPID {
misi String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
}
@@ -381,7 +383,7 @@ model DasarHukumPPID {
content String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
}
@@ -398,7 +400,7 @@ model ProfilePPID {
imageId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
}
@@ -410,7 +412,7 @@ model DaftarInformasiPublik {
tanggal DateTime @db.Date
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
}
@@ -431,7 +433,7 @@ model PermohonanInformasiPublik {
caraMemperolehSalinanInformasiId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
}
@@ -440,7 +442,7 @@ model JenisInformasiDiminta {
name String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
PermohonanInformasiPublik PermohonanInformasiPublik[]
}
@@ -450,7 +452,7 @@ model CaraMemperolehInformasi {
name String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
PermohonanInformasiPublik PermohonanInformasiPublik[]
}
@@ -460,7 +462,7 @@ model CaraMemperolehSalinanInformasi {
name String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
PermohonanInformasiPublik PermohonanInformasiPublik[]
}
@@ -474,7 +476,7 @@ model FormulirPermohonanKeberatan {
alasan String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
}
@@ -531,7 +533,7 @@ model SejarahDesa {
deskripsi String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
}
@@ -541,7 +543,7 @@ model VisiMisiDesa {
misi String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
}
@@ -551,7 +553,7 @@ model LambangDesa {
deskripsi String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
}
@@ -562,7 +564,7 @@ model MaskotDesa {
images ProfileDesaImage[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
}
@@ -631,25 +633,25 @@ model KategoriBerita {
// ========================================= POTENSI DESA ========================================= //
model PotensiDesa {
id String @id @default(cuid())
name String
deskripsi String
name String @unique @db.VarChar(255)
deskripsi String @db.Text
kategori KategoriPotensi? @relation(fields: [kategoriId], references: [id])
kategoriId String?
kategoriId String @db.VarChar(36)
image FileStorage? @relation(fields: [imageId], references: [id])
imageId String?
content String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
}
model KategoriPotensi {
id String @id @default(cuid())
nama String
nama String @unique @db.VarChar(100)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
deletedAt DateTime?
isActive Boolean @default(true)
PotensiDesa PotensiDesa[]
}

View File

@@ -69,8 +69,8 @@ import { seedProfilPpd } from "./_seeder_list/ppid/profil-ppid/seed_profil_ppd";
(async () => {
// Always run seedAssets to handle new images without duplication
// console.log("📂 Checking for new assets to seed...");
// await seedAssets();
console.log("📂 Checking for new assets to seed...");
await seedAssets();
// // =========== FILE STORAGE ===========

View File

@@ -1,7 +1,11 @@
'use client';
import React from 'react';
import { Grid, GridCol, Paper, TextInput, Title } from '@mantine/core';
import { Grid, GridCol, Paper, TextInput } from '@mantine/core';
import { IconSearch } from '@tabler/icons-react';
import colors from '@/con/colors';
import { useDarkMode } from '@/state/darkModeStore';
import { themeTokens } from '@/utils/themeTokens';
import { UnifiedTitle } from '@/components/admin/UnifiedTypography';
type HeaderSearchProps = {
title: string;
@@ -18,13 +22,16 @@ const HeaderSearch = ({
value,
onChange,
}: HeaderSearchProps) => {
const { isDark } = useDarkMode();
const tokens = themeTokens(isDark);
return (
<Grid mb={10}>
<GridCol span={{ base: 12, md: 9 }}>
<Title order={3}>{title}</Title>
<UnifiedTitle order={3}>{title}</UnifiedTitle>
</GridCol>
<GridCol span={{ base: 12, md: 3 }}>
<Paper radius="lg" bg={colors['white-1']}>
<Paper radius="lg" bg={tokens.colors.bg.surface}>
<TextInput
radius="lg"
placeholder={placeholder}
@@ -32,6 +39,16 @@ const HeaderSearch = ({
w="100%"
value={value}
onChange={onChange}
style={{
input: {
backgroundColor: tokens.colors.bg.surface,
color: tokens.colors.text.primary,
borderColor: tokens.colors.border.default,
'::placeholder': {
color: tokens.colors.text.muted,
},
},
}}
/>
</Paper>
</GridCol>

View File

@@ -1,12 +1,16 @@
'use client'
import colors from '@/con/colors';
import { Grid, GridCol, Button, Text } from '@mantine/core';
import { Grid, GridCol, Button } from '@mantine/core';
import { IconCircleDashedPlus } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import React from 'react';
import { useDarkMode } from '@/state/darkModeStore';
import { themeTokens } from '@/utils/themeTokens';
import { UnifiedText } from '@/components/admin/UnifiedTypography';
const JudulList = ({ title = "", href = "#" }) => {
const { isDark } = useDarkMode();
const tokens = themeTokens(isDark);
const router = useRouter();
const handleNavigate = () => {
@@ -16,10 +20,18 @@ const JudulList = ({ title = "", href = "#" }) => {
return (
<Grid align="center" mb={10}>
<GridCol span={{ base: 12, md: 11 }}>
<Text fz={"xl"} fw={"bold"}>{title}</Text>
<UnifiedText size="body" weight="bold" color="primary">{title}</UnifiedText>
</GridCol>
<GridCol span={{ base: 12, md: 1 }} ta="right">
<Button onClick={handleNavigate} bg={colors['blue-button']}>
<Button
onClick={handleNavigate}
bg={tokens.colors.primary}
style={{
background: `linear-gradient(135deg, ${tokens.colors.primary}, ${isDark ? '#60A5FA' : '#4facfe'})`,
color: tokens.colors.text.inverse,
boxShadow: isDark ? 'none' : `0 4px 15px rgba(79, 172, 254, 0.4)`,
}}
>
<IconCircleDashedPlus size={25} />
</Button>
</GridCol>

View File

@@ -1,9 +1,11 @@
'use client'
import colors from '@/con/colors';
import { Grid, GridCol, Button, Text, Paper, TextInput } from '@mantine/core';
import { Grid, GridCol, Button, Paper, TextInput } from '@mantine/core';
import { IconCircleDashedPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import React from 'react';
import { useDarkMode } from '@/state/darkModeStore';
import { themeTokens } from '@/utils/themeTokens';
import { UnifiedText } from '@/components/admin/UnifiedTypography';
type JudulListTabProps = {
title: string;
@@ -14,17 +16,16 @@ type JudulListTabProps = {
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
const JudulListTab = ({
title = "",
href = "#",
placeholder = "pencarian",
searchIcon = <IconSearch size={20} />,
value,
onChange
onChange
}: JudulListTabProps) => {
const { isDark } = useDarkMode();
const tokens = themeTokens(isDark);
const router = useRouter();
const handleNavigate = () => {
@@ -34,10 +35,17 @@ const JudulListTab = ({
return (
<Grid mb={10}>
<GridCol span={{ base: 12, md: 8 }}>
<Text fz={{ base: "md", md: "xl" }} fw={"bold"}>{title}</Text>
<UnifiedText
size="body"
weight="bold"
color="primary"
style={{ fontSize: 'clamp(1rem, 2vw, 1.25rem)' }}
>
{title}
</UnifiedText>
</GridCol>
<GridCol span={{ base: 9, md: 3 }} ta="right">
<Paper radius={"lg"} bg={colors['white-1']}>
<Paper radius={"lg"} bg={tokens.colors.bg.surface}>
<TextInput
radius="lg"
placeholder={placeholder}
@@ -45,11 +53,29 @@ const JudulListTab = ({
w="100%"
value={value}
onChange={onChange}
style={{
input: {
backgroundColor: tokens.colors.bg.surface,
color: tokens.colors.text.primary,
borderColor: tokens.colors.border.default,
'::placeholder': {
color: tokens.colors.text.muted,
},
},
}}
/>
</Paper>
</GridCol>
<GridCol span={{ base: 3, md: 1 }} ta="right">
<Button onClick={handleNavigate} bg={colors['blue-button']}>
<Button
onClick={handleNavigate}
bg={tokens.colors.primary}
style={{
background: `linear-gradient(135deg, ${tokens.colors.primary}, ${isDark ? '#60A5FA' : '#4facfe'})`,
color: tokens.colors.text.inverse,
boxShadow: isDark ? 'none' : `0 4px 15px rgba(79, 172, 254, 0.4)`,
}}
>
<IconCircleDashedPlus size={25} />
</Button>
</GridCol>

View File

@@ -160,7 +160,7 @@ function ListKategoriBerita({ search }: { search: string }) {
))
) : (
<TableTr>
<TableTd colSpan={4}>
<TableTd colSpan={3}> {/* ✅ Match column count (3 columns) */}
<Center py={24}>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data kategori berita yang cocok

View File

@@ -187,7 +187,7 @@ function ListBerita({ search }: { search: string }) {
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10);
load(newPage, 10, debouncedSearch); // ✅ Include search parameter
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}

View File

@@ -8,6 +8,7 @@ import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import DOMPurify from 'dompurify';
export default function DetailPotensi() {
const router = useRouter();
@@ -77,7 +78,17 @@ export default function DetailPotensi() {
<Box>
<Text fz="lg" fw="bold">Deskripsi</Text>
<Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }}></Text>
<Text
fz="md"
c="dimmed"
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(data.deskripsi || '-', {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'ul', 'ol', 'li'],
ALLOWED_ATTR: []
})
}}
></Text>
</Box>
<Box>
@@ -102,7 +113,12 @@ export default function DetailPotensi() {
<Text
fz="md"
c="dimmed"
dangerouslySetInnerHTML={{ __html: data.content || '-' }}
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(data.content || '-', {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'ul', 'ol', 'li'],
ALLOWED_ATTR: []
})
}}
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
/>
</Box>

View File

@@ -27,6 +27,7 @@ import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header';
import potensiDesaState from '../../../_state/desa/potensi';
import { useDebouncedValue } from '@mantine/hooks';
import DOMPurify from 'dompurify';
function Potensi() {
const [search, setSearch] = useState("");
@@ -137,7 +138,12 @@ function ListPotensi({ search }: { search: string }) {
fz="sm"
lh={1.5}
lineClamp={2}
dangerouslySetInnerHTML={{ __html: item.deskripsi }}
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(item.deskripsi, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'ul', 'ol', 'li'],
ALLOWED_ATTR: []
})
}}
style={{ wordBreak: 'break-word' }}
/>
</TableTd>
@@ -199,7 +205,12 @@ function ListPotensi({ search }: { search: string }) {
<Text
fz="sm"
lh={1.5}
dangerouslySetInnerHTML={{ __html: item.deskripsi }}
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(item.deskripsi, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'ul', 'ol', 'li'],
ALLOWED_ATTR: []
})
}}
style={{ wordBreak: 'break-word' }}
/>
</Box>

View File

@@ -95,7 +95,7 @@ function Page() {
fz={{ base: 'md', md: 'lg' }}
lh={{ base: 1.4, md: 1.4 }}
>
I.B. Surya Prabhawa Manuaba, S.H., M.H.
{perbekel.nama || "I.B. Surya Prabhawa Manuaba, S.H., M.H."}
</Text>
</Paper>
</Stack>

View File

@@ -44,6 +44,21 @@ function EditDigitalSmartVillage() {
imageUrl: '',
});
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.name?.trim() !== '' &&
!isHtmlEmpty(formData.deskripsi)
);
};
useEffect(() => {
const loadData = async () => {
const id = params?.id as string;
@@ -248,8 +263,11 @@ function EditDigitalSmartVillage() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -30,6 +30,22 @@ export default function CreateDesaDigital() {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
stateDesaDigital.create.form.name?.trim() !== '' &&
!isHtmlEmpty(stateDesaDigital.create.form.deskripsi) &&
file !== null
);
};
const resetForm = () => {
stateDesaDigital.create.form = {
name: '',
@@ -227,8 +243,11 @@ export default function CreateDesaDigital() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -44,6 +44,21 @@ function EditInfoTeknologiTepatGuna() {
imageUrl: '',
});
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.name?.trim() !== '' &&
!isHtmlEmpty(formData.deskripsi)
);
};
// Load data pertama kali
useEffect(() => {
const id = params?.id as string;
@@ -260,8 +275,11 @@ function EditInfoTeknologiTepatGuna() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -30,6 +30,22 @@ function CreateInfoTeknologiTepatGuna() {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
stateInfoTekno.create.form.name?.trim() !== '' &&
!isHtmlEmpty(stateInfoTekno.create.form.deskripsi) &&
file !== null
);
};
const resetForm = () => {
stateInfoTekno.create.form = {
name: '',
@@ -202,8 +218,11 @@ function CreateInfoTeknologiTepatGuna() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -44,6 +44,23 @@ function EditKolaborasiInovasi() {
kolaborator: "",
});
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.name?.trim() !== '' &&
formData.slug?.trim() !== '' &&
!isHtmlEmpty(formData.deskripsi) &&
formData.kolaborator?.trim() !== ''
);
};
// Load data awal dari server
useEffect(() => {
const loadKolaborasi = async () => {
@@ -199,8 +216,11 @@ function EditKolaborasiInovasi() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -16,6 +16,22 @@ function CreateProgramKreatifDesa() {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
stateCreate.create.form.name?.trim() !== '' &&
stateCreate.create.form.slug?.trim() !== '' &&
!isHtmlEmpty(stateCreate.create.form.deskripsi)
);
};
const resetForm = () => {
stateCreate.create.form = {
name: "",
@@ -135,8 +151,11 @@ function CreateProgramKreatifDesa() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -51,6 +51,11 @@ function EditMitraKolaborasi() {
imageUrl: '',
});
// Check if form is valid
const isFormValid = () => {
return formData.name?.trim() !== '';
};
// Load data ke state lokal sekali saja
useEffect(() => {
const loadData = async () => {
@@ -263,8 +268,11 @@ function EditMitraKolaborasi() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -29,6 +29,14 @@ function CreateMitraKolaborasi() {
const [file, setFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Check if form is valid
const isFormValid = () => {
return (
state.create.form.name?.trim() !== '' &&
file !== null
);
};
const resetForm = () => {
state.create.form = {
name: '',
@@ -181,8 +189,11 @@ function CreateMitraKolaborasi() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -51,6 +51,23 @@ function EditProgramKreatifDesa() {
const [isDataChanged, setIsDataChanged] = useState(false);
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.name?.trim() !== '' &&
formData.slug?.trim() !== '' &&
!isHtmlEmpty(formData.deskripsi) &&
formData.icon?.trim() !== ''
);
};
// Load data hanya sekali berdasarkan params.id
useEffect(() => {
const loadProgramKreatif = async () => {
@@ -236,8 +253,11 @@ function EditProgramKreatifDesa() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -25,6 +25,23 @@ function CreateProgramKreatifDesa() {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
stateCreate.create.form.name?.trim() !== '' &&
stateCreate.create.form.icon?.trim() !== '' &&
stateCreate.create.form.slug?.trim() !== '' &&
!isHtmlEmpty(stateCreate.create.form.deskripsi)
);
};
const resetForm = () => {
stateCreate.create.form = {
name: "",
@@ -127,8 +144,11 @@ function CreateProgramKreatifDesa() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -67,6 +67,23 @@ export default function EditDataLingkunganDesa() {
icon: '',
});
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.name?.trim() !== '' &&
formData.jumlah?.trim() !== '' &&
!isHtmlEmpty(formData.deskripsi) &&
formData.icon?.trim() !== ''
);
};
// Load data saat komponen mount
useEffect(() => {
const loadData = async () => {
@@ -211,8 +228,11 @@ export default function EditDataLingkunganDesa() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -25,6 +25,23 @@ function CreateDataLingkunganDesa() {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
stateCreate.create.form.name?.trim() !== '' &&
stateCreate.create.form.icon?.trim() !== '' &&
stateCreate.create.form.jumlah?.trim() !== '' &&
!isHtmlEmpty(stateCreate.create.form.deskripsi)
);
};
const resetForm = () => {
stateCreate.create.form = {
name: '',
@@ -129,8 +146,11 @@ function CreateDataLingkunganDesa() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -38,6 +38,21 @@ export default function EditContohKegiatanDesaDarmasaba() {
deskripsi: '',
});
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
!isHtmlEmpty(formData.judul) &&
!isHtmlEmpty(formData.deskripsi)
);
};
// load data awal
useShallowEffect(() => {
if (!contohEdukasiState.findById.data) {
@@ -156,8 +171,11 @@ export default function EditContohKegiatanDesaDarmasaba() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -27,6 +27,21 @@ export default function EditMateriEdukasiYangDiberikan() {
content: '',
});
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
!isHtmlEmpty(formData.judul) &&
!isHtmlEmpty(formData.content)
);
};
// Initialize data kalau belum ada
useShallowEffect(() => {
if (!materiEdukasiState.findById.data) {
@@ -139,8 +154,11 @@ export default function EditMateriEdukasiYangDiberikan() {
onClick={submit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -28,6 +28,21 @@ export default function EditTujuanEdukasiLingkungan() {
deskripsi: '',
});
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
!isHtmlEmpty(formData.judul) &&
!isHtmlEmpty(formData.deskripsi)
);
};
// Initialize global state
useShallowEffect(() => {
if (!tujuanEdukasiState.findById.data) {
@@ -147,8 +162,11 @@ export default function EditTujuanEdukasiLingkungan() {
onClick={submit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -21,6 +21,11 @@ function EditKategoriKegiatan() {
const [originalData, setOriginalData] = useState({ nama: '' });
const [loading, setLoading] = useState(true);
// Check if form is valid
const isFormValid = () => {
return formData.nama?.trim() !== '';
};
// Load data once
useEffect(() => {
if (!id) return;
@@ -126,8 +131,11 @@ function EditKategoriKegiatan() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -14,6 +14,11 @@ function CreateKategoriKegiatan() {
const stateKategori = useProxy(gotongRoyongState.kategoriKegiatan)
const [isSubmitting, setIsSubmitting] = useState(false);
// Check if form is valid
const isFormValid = () => {
return stateKategori.create.form.nama?.trim() !== '';
};
useEffect(() => {
stateKategori.findMany.load();
}, []);
@@ -84,8 +89,11 @@ function CreateKategoriKegiatan() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -67,6 +67,27 @@ export default function EditKegiatanDesa() {
const [file, setFile] = useState<File | null>(null);
const [previewImage, setPreviewImage] = useState<string | null>(null);
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.judul?.trim() !== '' &&
!isHtmlEmpty(formData.deskripsiSingkat) &&
!isHtmlEmpty(formData.deskripsiLengkap) &&
formData.tanggal?.trim() !== '' &&
formData.lokasi?.trim() !== '' &&
formData.partisipan !== null &&
formData.partisipan >= 0 &&
formData.kategoriKegiatanId?.trim() !== ''
);
};
const formatDateForInput = (dateString: string) => {
if (!dateString) return '';
return new Date(dateString).toISOString().split('T')[0];
@@ -312,8 +333,11 @@ export default function EditKegiatanDesa() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -38,6 +38,28 @@ function CreateKegiatanDesa() {
const [isSubmitting, setIsSubmitting] = useState(false);
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
stateKegiatanDesa.create.form.judul?.trim() !== '' &&
!isHtmlEmpty(stateKegiatanDesa.create.form.deskripsiSingkat) &&
stateKegiatanDesa.create.form.partisipan !== null &&
stateKegiatanDesa.create.form.partisipan >= 0 &&
stateKegiatanDesa.create.form.tanggal !== null &&
stateKegiatanDesa.create.form.lokasi?.trim() !== '' &&
!isHtmlEmpty(stateKegiatanDesa.create.form.deskripsiLengkap) &&
stateKegiatanDesa.create.form.kategoriKegiatanId?.trim() !== '' &&
file !== null
);
};
const resetForm = () => {
stateKegiatanDesa.create.form = {
judul: '',
@@ -273,8 +295,11 @@ function CreateKegiatanDesa() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -27,6 +27,21 @@ function EditBentukKonservasiBerdasarkanAdat() {
deskripsi: '',
});
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
!isHtmlEmpty(formData.judul) &&
!isHtmlEmpty(formData.deskripsi)
);
};
// Initialize data dari global state
useShallowEffect(() => {
if (!bentukKonservasiState.findById.data) {
@@ -137,8 +152,11 @@ function EditBentukKonservasiBerdasarkanAdat() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -31,6 +31,21 @@ function EditFilosofiTriHitaKarana() {
content: '',
});
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
!isHtmlEmpty(formData.judul) &&
!isHtmlEmpty(formData.content)
);
};
// Load data dari global state kalau belum ada
useShallowEffect(() => {
if (!filosofiTriHitaState.findById.data) {
@@ -142,8 +157,11 @@ function EditFilosofiTriHitaKarana() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -24,6 +24,21 @@ function EditNilaiKonservasiAdat() {
const [formData, setFormData] = useState({ judul: '', deskripsi: '' });
const [originalData, setOriginalData] = useState({ judul: '', deskripsi: '' });
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
!isHtmlEmpty(formData.judul) &&
!isHtmlEmpty(formData.deskripsi)
);
};
// load data awal
useShallowEffect(() => {
if (!nilaiKonservasiState.findById.data) {
@@ -136,8 +151,11 @@ function EditNilaiKonservasiAdat() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -35,6 +35,16 @@ function EditKeteranganBankSampahTerdekat() {
lng: 0,
});
// Check if form is valid
const isFormValid = () => {
return (
formData.name?.trim() !== '' &&
formData.alamat?.trim() !== '' &&
formData.namaTempatMaps?.trim() !== '' &&
markerPosition !== null
);
};
// Load data ketika component mount
useEffect(() => {
const loadKeterangan = async () => {
@@ -197,8 +207,11 @@ function EditKeteranganBankSampahTerdekat() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -19,6 +19,16 @@ function CreateKeteranganBankSampahTerdekat() {
const [markerPosition, setMarkerPosition] = useState<{ lat: number; lng: number } | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Check if form is valid
const isFormValid = () => {
return (
keteranganState.create.form.name?.trim() !== '' &&
keteranganState.create.form.alamat?.trim() !== '' &&
keteranganState.create.form.namaTempatMaps?.trim() !== '' &&
markerPosition !== null
);
};
const resetForm = () => {
keteranganState.create.form = {
name: "",
@@ -135,8 +145,11 @@ function CreateKeteranganBankSampahTerdekat() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -34,6 +34,14 @@ function EditProgramKreatifDesa() {
icon: '',
});
// Check if form is valid
const isFormValid = () => {
return (
formData.name?.trim() !== '' &&
formData.icon?.trim() !== ''
);
};
useEffect(() => {
const loadProgramKreatif = async () => {
const id = params?.id as string;
@@ -143,8 +151,11 @@ function EditProgramKreatifDesa() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -13,6 +13,14 @@ function CreatePengelolaanSampahBankSampah() {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
// Check if form is valid
const isFormValid = () => {
return (
stateCreate.create.form.name?.trim() !== '' &&
stateCreate.create.form.icon?.trim() !== ''
);
};
const resetForm = () => {
stateCreate.create.form = {
name: "",
@@ -91,8 +99,11 @@ function CreatePengelolaanSampahBankSampah() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -64,6 +64,23 @@ function EditProgramPenghijauan() {
icon: '',
});
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.name?.trim() !== '' &&
formData.judul?.trim() !== '' &&
!isHtmlEmpty(formData.deskripsi) &&
formData.icon?.trim() !== ''
);
};
// Load data program penghijauan
useEffect(() => {
const loadProgram = async () => {
@@ -216,8 +233,11 @@ function EditProgramPenghijauan() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -25,6 +25,23 @@ function CreateProgramPenghijauan() {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
stateCreate.create.form.name?.trim() !== '' &&
stateCreate.create.form.icon?.trim() !== '' &&
stateCreate.create.form.judul?.trim() !== '' &&
!isHtmlEmpty(stateCreate.create.form.deskripsi)
);
};
const resetForm = () => {
stateCreate.create.form = {
name: '',
@@ -128,8 +145,11 @@ function CreateProgramPenghijauan() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -24,6 +24,21 @@ function EditProgramKreatifDesa() {
deskripsi: '',
})
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.judul?.trim() !== '' &&
!isHtmlEmpty(formData.deskripsi)
);
};
useEffect(() => {
const loadProgramKreatif = async () => {
const id = params?.id as string;
@@ -160,8 +175,11 @@ function EditProgramKreatifDesa() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -16,6 +16,21 @@ function CreateKeunggulanProgram() {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
stateCreate.create.form.judul?.trim() !== '' &&
!isHtmlEmpty(stateCreate.create.form.deskripsi)
);
};
const resetForm = () => {
stateCreate.create.form = {
judul: "",
@@ -97,8 +112,11 @@ function CreateKeunggulanProgram() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -42,6 +42,21 @@ function EditFasilitasYangDisediakan() {
deskripsi: '',
});
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.judul?.trim() !== '' &&
!isHtmlEmpty(formData.deskripsi)
);
};
// Load data pertama kali
useShallowEffect(() => {
if (!editState.findById.data) {
@@ -76,11 +91,6 @@ function EditFasilitasYangDisediakan() {
};
const handleSubmit = async () => {
if (!formData.judul.trim()) {
toast.error('Judul wajib diisi');
return;
}
setIsSubmitting(true);
try {
if (editState.findById.data) {
@@ -180,8 +190,11 @@ function EditFasilitasYangDisediakan() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -39,6 +39,21 @@ function EditLokasiDanJadwal() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({ judul: '', deskripsi: '' });
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.judul?.trim() !== '' &&
!isHtmlEmpty(formData.deskripsi)
);
};
// Load data sekali
useShallowEffect(() => {
if (!editState.findById.data) {
@@ -73,11 +88,6 @@ function EditLokasiDanJadwal() {
};
const handleSubmit = async () => {
if (!formData.judul.trim()) {
toast.error('Judul wajib diisi');
return;
}
setIsSubmitting(true);
try {
if (editState.findById.data) {
@@ -178,8 +188,11 @@ function EditLokasiDanJadwal() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -39,6 +39,21 @@ function EditTujuanProgram() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({ judul: '', deskripsi: '' });
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.judul?.trim() !== '' &&
!isHtmlEmpty(formData.deskripsi)
);
};
// load data sekali
useShallowEffect(() => {
if (!editState.findById.data) editState.findById.initialize();
@@ -71,11 +86,6 @@ function EditTujuanProgram() {
};
const handleSubmit = async () => {
if (!formData.judul.trim()) {
toast.error('Judul wajib diisi');
return;
}
setIsSubmitting(true);
try {
if (editState.findById.data) {
@@ -170,8 +180,11 @@ function EditTujuanProgram() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -28,6 +28,14 @@ export default function EditDataPendidikan() {
jumlah: '',
});
// Check if form is valid
const isFormValid = () => {
return (
formData.name?.trim() !== '' &&
formData.jumlah?.trim() !== ''
);
};
// Load data saat mount
useEffect(() => {
if (id) {
@@ -127,8 +135,11 @@ export default function EditDataPendidikan() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -15,6 +15,14 @@ export default function CreateDataPendidikan() {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
// Check if form is valid
const isFormValid = () => {
return (
stateDPM.create.form.name?.trim() !== '' &&
stateDPM.create.form.jumlah?.trim() !== ''
);
};
const resetForm = () => {
stateDPM.create.form = { name: '', jumlah: '' };
};
@@ -90,8 +98,11 @@ export default function CreateDataPendidikan() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -31,6 +31,11 @@ function EditJenjangPendidikan() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [loading, setLoading] = useState(true);
// Check if form is valid
const isFormValid = () => {
return formData.nama?.trim() !== '';
};
// Load data sekali saat component mount
useEffect(() => {
if (!id) return;
@@ -136,8 +141,11 @@ function EditJenjangPendidikan() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -23,6 +23,11 @@ function CreateJenjangPendidikan() {
const stateJenjang = useProxy(infoSekolahPaud.jenjangPendidikan);
const [isSubmitting, setIsSubmitting] = useState(false);
// Check if form is valid
const isFormValid = () => {
return stateJenjang.create.form.nama?.trim() !== '';
};
useEffect(() => {
stateJenjang.findMany.load();
}, []);
@@ -101,8 +106,11 @@ function CreateJenjangPendidikan() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -37,6 +37,14 @@ export default function EditLembaga() {
jenjangId: '',
});
// Check if form is valid
const isFormValid = () => {
return (
form.nama?.trim() !== '' &&
form.jenjangId?.trim() !== ''
);
};
// Load jenjang pendidikan dan data lembaga
useEffect(() => {
infoSekolahPaud.jenjangPendidikan.findMany.load();
@@ -161,8 +169,11 @@ export default function EditLembaga() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -25,6 +25,14 @@ function CreateLembaga() {
const stateLembaga = useProxy(infoSekolahPaud.lembagaPendidikan);
const [isSubmitting, setIsSubmitting] = useState(false);
// Check if form is valid
const isFormValid = () => {
return (
stateLembaga.create.form.nama?.trim() !== '' &&
stateLembaga.create.form.jenjangId?.trim() !== ''
);
};
useEffect(() => {
stateLembaga.findMany.load();
infoSekolahPaud.jenjangPendidikan.findMany.load();
@@ -116,8 +124,11 @@ function CreateLembaga() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -40,6 +40,14 @@ function EditPengajar() {
lembagaId: ''
});
// Check if form is valid
const isFormValid = () => {
return (
formData.nama?.trim() !== '' &&
formData.lembagaId?.trim() !== ''
);
};
useEffect(() => {
const loadData = async () => {
const id = params?.id as string;
@@ -157,8 +165,11 @@ function EditPengajar() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -25,6 +25,14 @@ function CreatePengajar() {
const stateCreate = useProxy(infoSekolahPaud.pengajar);
const [isSubmitting, setIsSubmitting] = useState(false);
// Check if form is valid
const isFormValid = () => {
return (
stateCreate.create.form.nama?.trim() !== '' &&
stateCreate.create.form.lembagaId?.trim() !== ''
);
};
useEffect(() => {
stateCreate.findMany.load();
infoSekolahPaud.lembagaPendidikan.findMany.load();
@@ -116,8 +124,11 @@ function CreatePengajar() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -42,6 +42,14 @@ function EditSiswa() {
lembagaId: '',
});
// Check if form is valid
const isFormValid = () => {
return (
formData.nama?.trim() !== '' &&
formData.lembagaId?.trim() !== ''
);
};
// Load data siswa
useEffect(() => {
const loadSiswa = async () => {
@@ -166,8 +174,11 @@ function EditSiswa() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -25,6 +25,14 @@ function CreateSiswa() {
const stateCreate = useProxy(infoSekolahPaud.siswa);
const [isSubmitting, setIsSubmitting] = useState(false);
// Check if form is valid
const isFormValid = () => {
return (
stateCreate.create.form.nama?.trim() !== '' &&
stateCreate.create.form.lembagaId?.trim() !== ''
);
};
useEffect(() => {
stateCreate.findMany.load();
infoSekolahPaud.lembagaPendidikan.findMany.load();
@@ -115,8 +123,11 @@ function CreateSiswa() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -37,6 +37,21 @@ function EditJenisProgramYangDiselenggarakan() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({ judul: '', content: '' });
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.judul?.trim() !== '' &&
!isHtmlEmpty(formData.content)
);
};
// Load data pertama kali
useShallowEffect(() => {
if (!editState.findById.data) {
@@ -71,11 +86,6 @@ function EditJenisProgramYangDiselenggarakan() {
};
const handleSubmit = async () => {
if (!formData.judul.trim()) {
toast.error('Judul wajib diisi');
return;
}
setIsSubmitting(true);
try {
if (editState.findById.data) {
@@ -168,8 +178,11 @@ function EditJenisProgramYangDiselenggarakan() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -45,6 +45,21 @@ function EditTempatKegiatan() {
deskripsi: '',
});
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.judul?.trim() !== '' &&
!isHtmlEmpty(formData.deskripsi)
);
};
// load data pertama kali
useShallowEffect(() => {
if (!editState.findById.data) {
@@ -79,11 +94,6 @@ function EditTempatKegiatan() {
};
const handleSubmit = async () => {
if (!formData.judul.trim()) {
toast.error('Judul wajib diisi');
return;
}
setIsSubmitting(true);
try {
if (editState.findById.data) {
@@ -177,8 +187,11 @@ function EditTempatKegiatan() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -38,6 +38,21 @@ function EditTujuanProgram() {
deskripsi: '',
});
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.judul?.trim() !== '' &&
!isHtmlEmpty(formData.deskripsi)
);
};
// Load data pertama kali
useShallowEffect(() => {
if (!editState.findById.data) {
@@ -72,11 +87,6 @@ function EditTujuanProgram() {
};
const handleSubmit = async () => {
if (!formData.judul.trim()) {
toast.error('Judul wajib diisi');
return;
}
if (!editState.findById.data) return;
setIsSubmitting(true);
@@ -163,8 +173,11 @@ function EditTujuanProgram() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -38,6 +38,22 @@ function EditPerpustakaanDigital() {
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.judul?.trim() !== '' &&
!isHtmlEmpty(formData.deskripsi) &&
formData.kategoriId?.trim() !== ''
);
};
// Load kategori & data awal
useEffect(() => {
perpustakaanDigitalState.kategoriBuku.findManyAll.load();
@@ -254,8 +270,11 @@ function EditPerpustakaanDigital() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -18,6 +18,23 @@ function CreateDataPerpustakaan() {
const [file, setFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
createState.create.form.judul?.trim() !== '' &&
!isHtmlEmpty(createState.create.form.deskripsi) &&
createState.create.form.kategoriId?.trim() !== '' &&
file !== null
);
};
useEffect(() => {
perpustakaanDigitalState.kategoriBuku.findManyAll.load();
}, []);
@@ -196,8 +213,11 @@ function CreateDataPerpustakaan() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -23,6 +23,11 @@ function EditKategoriBuku() {
const [formData, setFormData] = useState({ name: '' });
const [loading, setLoading] = useState(true);
// Check if form is valid
const isFormValid = () => {
return formData.name?.trim() !== '';
};
useEffect(() => {
const loadKategori = async () => {
const id = params?.id as string;
@@ -120,8 +125,11 @@ function EditKategoriBuku() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -13,6 +13,11 @@ function CreateKategoriBuku() {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
// Check if form is valid
const isFormValid = () => {
return createState.create.form.name?.trim() !== '';
};
const resetForm = () => {
createState.create.form = {
name: "",
@@ -81,8 +86,11 @@ function CreateKategoriBuku() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -70,6 +70,26 @@ function EditPeminjam() {
catatan: "",
})
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.nama?.trim() !== '' &&
formData.noTelp?.trim() !== '' &&
formData.alamat?.trim() !== '' &&
formData.bukuId?.trim() !== '' &&
formData.tanggalPinjam?.trim() !== '' &&
formData.status?.trim() !== '' &&
!isHtmlEmpty(formData.catatan)
);
};
useShallowEffect(() => {
perpustakaanDigitalState.dataPerpustakaan.findManyAll.load()
})
@@ -296,8 +316,11 @@ function EditPeminjam() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -50,6 +50,21 @@ function EditTujuanProgram() {
});
const [isSubmitting, setIsSubmitting] = useState(false);
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.judul?.trim() !== '' &&
!isHtmlEmpty(formData.deskripsi)
);
};
// load data once
useShallowEffect(() => {
if (!editState.findById.data) editState.findById.initialize();
@@ -85,11 +100,6 @@ function EditTujuanProgram() {
};
const handleSubmit = async () => {
if (!formData.judul.trim()) {
toast.error('Judul wajib diisi');
return;
}
setIsSubmitting(true);
try {
if (editState.findById.data) {
@@ -186,8 +196,11 @@ function EditTujuanProgram() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -38,6 +38,21 @@ function EditTujuanProgram() {
deskripsi: '',
});
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
formData.judul?.trim() !== '' &&
!isHtmlEmpty(formData.deskripsi)
);
};
// Load data pertama kali
useShallowEffect(() => {
if (!editState.findById.data) {
@@ -72,11 +87,6 @@ function EditTujuanProgram() {
};
const handleSubmit = async () => {
if (!formData.judul.trim()) {
toast.error('Judul wajib diisi');
return;
}
setIsSubmitting(true);
try {
if (editState.findById.data) {
@@ -166,8 +176,11 @@ function EditTujuanProgram() {
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -1,7 +1,9 @@
'use client'
import colors from "@/con/colors";
import { authStore } from "@/store/authStore";
import { useDarkMode } from "@/state/darkModeStore";
import { themeTokens, getActiveStateStyles } from "@/utils/themeTokens";
import { DarkModeToggle } from "@/components/admin/DarkModeToggle";
import {
ActionIcon,
AppShell,
@@ -33,13 +35,21 @@ import { useEffect, useState } from "react";
import { getNavbar } from "./(dashboard)/user&role/_com/dynamicNavbar";
export default function Layout({ children }: { children: React.ReactNode }) {
const [opened, { toggle, close }] = useDisclosure(); // ✅ Tambahkan 'close'
const { isDark } = useDarkMode();
const tokens = themeTokens(isDark);
const [mounted, setMounted] = useState(false);
const [opened, { toggle, close }] = useDisclosure();
const [loading, setLoading] = useState(true);
const [isLoggingOut, setIsLoggingOut] = useState(false);
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
const router = useRouter();
const segments = useSelectedLayoutSegments().map((s) => _.lowerCase(s));
// Ensure component is mounted on client side
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
const fetchUser = async () => {
@@ -74,7 +84,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
});
const currentPath = window.location.pathname;
if (currentPath === '/admin') {
const expectedPath = getRedirectPath(Number(data.user.roleId));
console.log('🔄 Redirecting from /admin to:', expectedPath);
@@ -112,11 +122,11 @@ export default function Layout({ children }: { children: React.ReactNode }) {
}
};
if (loading) {
if (loading || !mounted) {
return (
<AppShell>
<AppShellMain>
<Center h="100vh">
<Center h="100vh" bg="#f6f9fc">
<Loader />
</Center>
</AppShellMain>
@@ -132,7 +142,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
try {
setIsLoggingOut(true);
const response = await fetch('/api/auth/logout', {
const response = await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include'
});
@@ -158,10 +168,9 @@ export default function Layout({ children }: { children: React.ReactNode }) {
}
};
// ✅ Handler untuk menutup mobile menu saat navigasi
const handleNavClick = (path: string) => {
router.push(path);
close(); // Tutup mobile menu
close();
};
return (
@@ -178,11 +187,16 @@ export default function Layout({ children }: { children: React.ReactNode }) {
}}
padding="md"
>
{/*
HEADER / TOPBAR
Spec: Background gradient, border bawah wajib
*/}
<AppShellHeader
style={{
background: "linear-gradient(90deg, #ffffff, #f9fbff)",
borderBottom: `1px solid ${colors["blue-button"]}20`,
background: mounted ? tokens.colors.bg.header : 'linear-gradient(90deg, #ffffff, #f9fbff)',
borderBottom: `1px solid ${mounted ? tokens.colors.border.soft : '#e9ecef'}`,
padding: '0 16px',
transition: 'background 0.3s ease, border-color 0.3s ease',
}}
px={{ base: 'sm', sm: 'md' }}
py={{ base: 'xs', sm: 'sm' }}
@@ -198,30 +212,49 @@ export default function Layout({ children }: { children: React.ReactNode }) {
loading="lazy"
style={{ minWidth: '32px', height: 'auto' }}
/>
<Text fw={700} c={colors["blue-button"]} fz={{ base: 'md', sm: 'xl' }}>
<Text fw={700} c={mounted ? tokens.colors.text.brand : '#0A4E78'} fz={{ base: 'md', sm: 'xl' }}>
Admin Darmasaba
</Text>
</Flex>
<Group gap="xs">
{/* Dark Mode Toggle */}
<DarkModeToggle variant="light" size="lg" showTooltip tooltipPosition="bottom" />
{!desktopOpened && (
<Tooltip label="Buka Navigasi" position="bottom" withArrow>
<ActionIcon variant="light" radius="xl" size="lg" onClick={toggleDesktop} color={colors["blue-button"]}>
<ActionIcon variant="light" radius="xl" size="lg" onClick={toggleDesktop} color={mounted ? tokens.colors.primary : '#3B82F6'}>
<IconChevronRight />
</ActionIcon>
</Tooltip>
)}
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="md" color={colors["blue-button"]} mr="xs" />
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="md" color={mounted ? tokens.colors.text.brand : '#0A4E78'} mr="xs" />
<Tooltip label="Kembali ke Website Desa" position="bottom" withArrow>
<ActionIcon onClick={() => router.push("/darmasaba")} color={colors["blue-button"]} radius="xl" size="lg" variant="gradient" gradient={{ from: colors["blue-button"], to: "#228be6" }}>
<ActionIcon
onClick={() => router.push("/darmasaba")}
color={mounted ? tokens.colors.primary : '#3B82F6'}
radius="xl"
size="lg"
variant="gradient"
gradient={mounted ? tokens.colors.gradient : { from: '#3B82F6', to: '#60A5FA' }}
>
<Image src="/assets/images/darmasaba-icon.png" alt="Logo Darmasaba" w={20} h={20} radius="md" loading="lazy" style={{ minWidth: '20px', height: 'auto' }} />
</ActionIcon>
</Tooltip>
<Tooltip label="Keluar" position="bottom" withArrow>
<ActionIcon onClick={handleLogout} color={colors["blue-button"]} radius="xl" size="lg" variant="gradient" gradient={{ from: colors["blue-button"], to: "#228be6" }} loading={isLoggingOut} disabled={isLoggingOut}>
<ActionIcon
onClick={handleLogout}
color={mounted ? tokens.colors.primary : '#3B82F6'}
radius="xl"
size="lg"
variant="gradient"
gradient={mounted ? tokens.colors.gradient : { from: '#3B82F6', to: '#60A5FA' }}
loading={isLoggingOut}
disabled={isLoggingOut}
>
<IconLogout2 size={22} />
</ActionIcon>
</Tooltip>
@@ -229,47 +262,105 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</Group>
</AppShellHeader>
<AppShellNavbar component={ScrollArea} style={{ background: "#ffffff", borderRight: `1px solid ${colors["blue-button"]}20` }} p={{ base: 'xs', sm: 'sm' }}>
{/*
SIDEBAR / NAVBAR
Spec: Background --bg-app, active state dengan accent bar
*/}
<AppShellNavbar
component={ScrollArea}
style={{
background: mounted ? tokens.colors.bg.app : '#ffffff',
borderRight: `1px solid ${mounted ? tokens.colors.border.soft : '#e9ecef'}`,
transition: 'background 0.3s ease, border-color 0.3s ease',
}}
p={{ base: 'xs', sm: 'sm' }}
>
<AppShell.Section p="sm">
{currentNav.map((v, k) => {
const isParentActive = segments.includes(_.lowerCase(v.name));
return (
<NavLink
key={k}
defaultOpened={isParentActive}
c={isParentActive ? colors["blue-button"] : "gray"}
label={<Text fw={isParentActive ? 600 : 400} fz="sm">{v.name}</Text>}
style={{ borderRadius: rem(10), marginBottom: rem(4), transition: "background 150ms ease" }}
styles={{ root: { '&:hover': { backgroundColor: 'rgba(25, 113, 194, 0.05)' } } }}
variant="light"
<NavLink
key={k}
defaultOpened={isParentActive}
c={mounted && isParentActive ? tokens.colors.primary : mounted && isDark ? '#E5E7EB' : tokens.colors.text.secondary}
label={
<Text
fw={isParentActive ? 600 : 400}
fz="sm"
style={{
color: mounted && isDark ? '#E5E7EB' : 'inherit',
transition: 'color 150ms ease',
}}
>
{v.name}
</Text>
}
style={{
borderRadius: rem(10),
marginBottom: rem(4),
transition: "background 150ms ease",
...(mounted && isParentActive && !isDark && {
borderLeft: `3px solid ${tokens.colors.primary}`,
}),
}}
styles={{
root: {
'&:hover': {
backgroundColor: mounted && isDark ? '#1E293B' : tokens.colors.bg.hover,
},
...(mounted && isParentActive && isDark && {
backgroundColor: 'rgba(59,130,246,0.25)',
borderLeft: `3px solid ${tokens.colors.primary}`,
}),
}
}}
variant="light"
active={isParentActive}
>
{v.children.map((child, key) => {
const isChildActive = segments.includes(_.lowerCase(child.name));
return (
<NavLink
key={key}
// ✅ PERBAIKAN: Gunakan onClick untuk handle navigasi dan close menu
<NavLink
key={key}
onClick={(e) => {
e.preventDefault();
handleNavClick(child.path);
}}
href={child.path}
c={isChildActive ? colors["blue-button"] : "gray"}
label={<Text fw={isChildActive ? 600 : 400} fz="sm">{child.name}</Text>}
styles={{
root: {
borderRadius: rem(8),
marginBottom: rem(2),
transition: 'background 150ms ease',
padding: '6px 12px',
'&:hover': {
backgroundColor: isChildActive ? 'rgba(25, 113, 194, 0.15)' : 'rgba(25, 113, 194, 0.05)'
},
...(isChildActive && { backgroundColor: 'rgba(25, 113, 194, 0.1)' })
}
}}
active={isChildActive}
c={mounted && isChildActive ? tokens.colors.primary : mounted && isDark ? '#E5E7EB' : tokens.colors.text.secondary}
label={
<Text
fw={isChildActive ? 600 : 400}
fz="sm"
style={{
color: mounted && isDark ? '#E5E7EB' : 'inherit',
transition: 'color 150ms ease',
}}
>
{child.name}
</Text>
}
styles={{
root: {
borderRadius: rem(8),
marginBottom: rem(2),
transition: 'background 150ms ease',
padding: '6px 12px',
'&:hover': {
backgroundColor: mounted && isDark ? 'rgba(255, 255, 255, 0.05)' : tokens.colors.bg.hover,
},
...(mounted && isChildActive && isDark && {
backgroundColor: 'rgba(59,130,246,0.15)',
borderLeft: `2px solid ${tokens.colors.primary}`,
}),
...(mounted && isChildActive && !isDark && {
backgroundColor: 'rgba(25, 113, 194, 0.1)',
borderLeft: `2px solid ${tokens.colors.primary}`,
}),
}
}}
active={isChildActive}
variant="subtle"
component={Link}
/>
);
@@ -282,7 +373,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<AppShell.Section py="md">
<Group justify="end" pr="sm">
<Tooltip label={desktopOpened ? "Tutup Navigasi" : "Buka Navigasi"} position="top" withArrow>
<ActionIcon variant="light" radius="xl" size="lg" onClick={toggleDesktop} color={colors["blue-button"]}>
<ActionIcon variant="light" radius="xl" size="lg" onClick={toggleDesktop} color={mounted ? tokens.colors.primary : '#3B82F6'}>
<IconChevronLeft />
</ActionIcon>
</Tooltip>
@@ -290,7 +381,17 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</AppShell.Section>
</AppShellNavbar>
<AppShellMain style={{ background: "linear-gradient(180deg, #fdfdfd, #f6f9fc)", minHeight: "100vh" }}>
{/*
MAIN CONTENT
Spec: Background --bg-base
*/}
<AppShellMain
style={{
background: mounted ? tokens.colors.bg.base : '#f6f9fc',
minHeight: "100vh",
transition: 'background 0.3s ease',
}}
>
{children}
</AppShellMain>
</AppShell>

View File

@@ -2,15 +2,50 @@ import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function kategoriBeritaDelete(context: Context) {
const id = context.params.id as string;
try {
const id = context.params?.id as string;
await prisma.kategoriBerita.delete({
where: { id },
});
if (!id) {
return Response.json({
success: false,
message: "ID tidak boleh kosong",
}, { status: 400 });
}
return {
status: 200,
success: true,
message: "Sukses Menghapus kategori berita",
};
// ✅ Cek apakah kategori masih digunakan oleh berita
const beritaCount = await prisma.berita.count({
where: {
kategoriBeritaId: id,
isActive: true,
deletedAt: null,
},
});
if (beritaCount > 0) {
return Response.json({
success: false,
message: `Kategori tidak dapat dihapus karena masih digunakan oleh ${beritaCount} berita`,
}, { status: 400 });
}
// ✅ Soft delete (bukan hard delete)
await prisma.kategoriBerita.update({
where: { id },
data: {
deletedAt: new Date(),
isActive: false,
},
});
return {
success: true,
message: "Kategori berita berhasil dihapus",
};
} catch (error) {
console.error("Delete kategori error:", error);
return Response.json({
success: false,
message: "Gagal menghapus kategori: " + (error instanceof Error ? error.message : 'Unknown error'),
}, { status: 500 });
}
}

View File

@@ -21,8 +21,13 @@ export default async function findUnique(
}, { status: 400 });
}
const data = await prisma.potensiDesa.findUnique({
where: { id },
// ✅ Filter by isActive and deletedAt
const data = await prisma.potensiDesa.findFirst({
where: {
id,
isActive: true,
deletedAt: null,
},
include: {
image: true,
kategori: true
@@ -48,5 +53,5 @@ export default async function findUnique(
message: "Gagal mengambil potensi desa: " + (error instanceof Error ? error.message : 'Unknown error'),
}, { status: 500 });
}
}

View File

@@ -2,15 +2,50 @@ import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function kategoriPotensiDelete(context: Context) {
const id = context.params.id as string;
try {
const id = context.params?.id as string;
await prisma.kategoriPotensi.delete({
where: { id },
});
if (!id) {
return Response.json({
success: false,
message: "ID tidak boleh kosong",
}, { status: 400 });
}
return {
status: 200,
success: true,
message: "Sukses Menghapus kategori potensi",
};
// ✅ Cek apakah kategori masih digunakan oleh potensi desa
const existingPotensi = await prisma.potensiDesa.findFirst({
where: {
kategoriId: id,
isActive: true,
deletedAt: null,
},
});
if (existingPotensi) {
return Response.json({
success: false,
message: "Kategori masih digunakan oleh potensi desa. Tidak dapat dihapus.",
}, { status: 400 });
}
// Soft delete
await prisma.kategoriPotensi.update({
where: { id },
data: {
deletedAt: new Date(),
isActive: false,
},
});
return {
success: true,
message: "Kategori potensi berhasil dihapus",
};
} catch (error) {
console.error("Delete kategori error:", error);
return Response.json({
success: false,
message: "Gagal menghapus kategori: " + (error instanceof Error ? error.message : 'Unknown error'),
}, { status: 500 });
}
}

View File

@@ -0,0 +1,40 @@
import prisma from "@/lib/prisma";
import { requireAuth } from "@/lib/api-auth";
export default async function sejarahDesaFindFirst(request: Request) {
// ✅ Authentication check
const headers = new Headers(request.url);
const authResult = await requireAuth({ headers });
if (!authResult.authenticated) {
return authResult.response;
}
try {
// Get the first active record
const data = await prisma.sejarahDesa.findFirst({
where: {
isActive: true,
deletedAt: null
},
orderBy: { createdAt: 'asc' } // Get the oldest one first
});
if (!data) {
return Response.json({
success: false,
message: "Data tidak ditemukan",
}, {status: 404})
}
return Response.json({
success: true,
data,
}, {status: 200})
} catch (error) {
console.error("Gagal mengambil data sejarah desa:", error)
return Response.json({
success: false,
message: "Terjadi kesalahan saat mengambil data",
}, {status: 500})
}
}

View File

@@ -1,11 +1,16 @@
import Elysia, { t } from "elysia";
import sejarahDesaFindById from "./find-by-id";
import sejarahDesaUpdate from "./update";
import sejarahDesaFindFirst from "./find-first";
const SejarahDesa = new Elysia({
prefix: "/sejarah",
tags: ["Desa/Profile"],
})
.get("/first", async (context) => {
const response = await sejarahDesaFindFirst(new Request(context.request));
return response;
})
.get("/:id", async (context) => {
const response = await sejarahDesaFindById(new Request(context.request));
return response;

View File

@@ -1,7 +1,14 @@
import prisma from "@/lib/prisma";
import { requireAuth } from "@/lib/api-auth";
import { Context } from "elysia";
export default async function sejarahDesaUpdate(context: Context) {
// ✅ Authentication check
const authResult = await requireAuth(context);
if (!authResult.authenticated) {
return authResult.response;
}
try {
const id = context.params?.id as string;
const body = await context.body as {

View File

@@ -12,6 +12,25 @@ function Page() {
const [opened, { open, close }] = useDisclosure(false);
const ideInovatif = useProxy(ajukanIdeInovatifState);
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
ideInovatif.create.form.name?.trim() !== '' &&
ideInovatif.create.form.alamat?.trim() !== '' &&
ideInovatif.create.form.namaIde?.trim() !== '' &&
!isHtmlEmpty(ideInovatif.create.form.deskripsi) &&
ideInovatif.create.form.masalah?.trim() !== '' &&
ideInovatif.create.form.benefit?.trim() !== ''
);
};
const resetForm = () => {
ideInovatif.create.form = {
name: "",
@@ -168,7 +187,11 @@ function Page() {
ideInovatif.create.form.benefit = val.target.value;
}}
/>
<Button bg={colors['blue-button']} onClick={handleSubmit}>
<Button
bg={colors['blue-button']}
onClick={handleSubmit}
disabled={!isFormValid()}
>
Simpan
</Button>
</Stack>

View File

@@ -24,6 +24,16 @@ function AdministrasiOnline() {
const [opened, { open, close }] = useDisclosure(false);
const state = useProxy(layananonlineDesa);
// Check if form is valid
const isFormValid = () => {
return (
state.administrasiOnline.create.form.name?.trim() !== '' &&
state.administrasiOnline.create.form.alamat?.trim() !== '' &&
state.administrasiOnline.create.form.nomorTelepon?.trim() !== '' &&
state.administrasiOnline.create.form.jenisLayananId?.trim() !== ''
);
};
useEffect(() => {
// ✅ Panggil load data jenis layanan dari backend
if (!state.jenisLayanan.findMany.data) {
@@ -104,7 +114,11 @@ function AdministrasiOnline() {
}
/>
<Button bg={colors['blue-button']} onClick={handleSubmit}>
<Button
bg={colors['blue-button']}
onClick={handleSubmit}
disabled={!isFormValid()}
>
Simpan
</Button>
</Stack>

View File

@@ -19,6 +19,28 @@ function PengaduanMasyarakat() {
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
state.pengaduanMasyarakat.create.form.name?.trim() !== '' &&
state.pengaduanMasyarakat.create.form.email?.trim() !== '' &&
state.pengaduanMasyarakat.create.form.nomorTelepon?.trim() !== '' &&
state.pengaduanMasyarakat.create.form.nik?.trim() !== '' &&
state.pengaduanMasyarakat.create.form.judulPengaduan?.trim() !== '' &&
state.pengaduanMasyarakat.create.form.lokasiKejadian?.trim() !== '' &&
!isHtmlEmpty(state.pengaduanMasyarakat.create.form.deskripsiPengaduan) &&
state.pengaduanMasyarakat.create.form.jenisPengaduanId?.trim() !== '' &&
file !== null
);
};
useEffect(() => {
// ✅ Panggil load data jenis layanan dari backend
if (!state.jenisPengaduan.findMany.data) {
@@ -207,7 +229,11 @@ function PengaduanMasyarakat() {
</Box>
</Box>
<Button bg={colors['blue-button']} onClick={handleSubmit}>
<Button
bg={colors['blue-button']}
onClick={handleSubmit}
disabled={!isFormValid()}
>
Simpan
</Button>
</Stack>

View File

@@ -37,6 +37,24 @@ function Page() {
};
};
// Check if form is valid
const isFormValid = () => {
return (
beasiswaDesa.create.form.namaLengkap?.trim() !== '' &&
beasiswaDesa.create.form.nis?.trim() !== '' &&
beasiswaDesa.create.form.kelas?.trim() !== '' &&
beasiswaDesa.create.form.jenisKelamin?.trim() !== '' &&
beasiswaDesa.create.form.alamatDomisili?.trim() !== '' &&
beasiswaDesa.create.form.tempatLahir?.trim() !== '' &&
beasiswaDesa.create.form.tanggalLahir?.trim() !== '' &&
beasiswaDesa.create.form.namaOrtu?.trim() !== '' &&
beasiswaDesa.create.form.nik?.trim() !== '' &&
beasiswaDesa.create.form.pekerjaanOrtu?.trim() !== '' &&
beasiswaDesa.create.form.penghasilan?.trim() !== '' &&
beasiswaDesa.create.form.noHp?.trim() !== ''
);
};
const { data, page, totalPages, loading, load } = ungggulanDesa.findMany;
useShallowEffect(() => {
@@ -238,7 +256,7 @@ function Page() {
onChange={(val) => { beasiswaDesa.create.form.noHp = val.target.value }} />
<Group justify="flex-end" mt="md">
<Button variant="default" radius="xl" onClick={close}>Batal</Button>
<Button radius="xl" bg={colors['blue-button']} onClick={handleSubmit}>Kirim</Button>
<Button radius="xl" bg={colors['blue-button']} onClick={handleSubmit} disabled={!isFormValid()}>Kirim</Button>
</Group>
</Stack>
</Paper>

View File

@@ -46,6 +46,24 @@ export default function BeasiswaPage() {
};
};
// Check if form is valid
const isFormValid = () => {
return (
beasiswaDesa.create.form.namaLengkap?.trim() !== '' &&
beasiswaDesa.create.form.nis?.trim() !== '' &&
beasiswaDesa.create.form.kelas?.trim() !== '' &&
beasiswaDesa.create.form.jenisKelamin?.trim() !== '' &&
beasiswaDesa.create.form.alamatDomisili?.trim() !== '' &&
beasiswaDesa.create.form.tempatLahir?.trim() !== '' &&
beasiswaDesa.create.form.tanggalLahir?.trim() !== '' &&
beasiswaDesa.create.form.namaOrtu?.trim() !== '' &&
beasiswaDesa.create.form.nik?.trim() !== '' &&
beasiswaDesa.create.form.pekerjaanOrtu?.trim() !== '' &&
beasiswaDesa.create.form.penghasilan?.trim() !== '' &&
beasiswaDesa.create.form.noHp?.trim() !== ''
);
};
const handleSubmit = async () => {
await beasiswaDesa.create.create();
resetForm();
@@ -391,6 +409,7 @@ export default function BeasiswaPage() {
radius="xl"
bg={colors['blue-button']}
onClick={handleSubmit}
disabled={!isFormValid()}
style={{ fontSize: '0.9375rem', fontWeight: 600, lineHeight: 1.4 }}
>
Kirim

View File

@@ -42,6 +42,24 @@ export default function ModalPeminjaman({
const BATAS_HARI_PINJAM = 4;
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
snap.create.form.nama?.trim() !== '' &&
snap.create.form.noTelp?.trim() !== '' &&
snap.create.form.alamat?.trim() !== '' &&
snap.create.form.tanggalPinjam?.trim() !== '' &&
!isHtmlEmpty(snap.create.form.catatan)
);
};
// Reset form setiap modal dibuka
useEffect(() => {
if (opened && buku) {
@@ -222,13 +240,13 @@ export default function ModalPeminjaman({
<Button
onClick={handleSubmit}
loading={snap.create.loading}
disabled={
!snap.create.form.nama || !snap.create.form.tanggalPinjam
}
disabled={!isFormValid() || snap.create.loading}
rightSection={<IconArrowRight size={16} />}
radius="xl"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
background: !isFormValid() || snap.create.loading
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}

View File

@@ -13,7 +13,7 @@ import { NavbarSubMenu } from "./NavbarSubMenu"
import { authStore } from "@/store/authStore";
// contoh state auth (dummy aja dulu, bisa diganti sesuai sistem auth kamu)
const isAdmin = authStore.user?.roleId === 0 || authStore.user?.roleId === 1 || authStore.user?.roleId === 2 || authStore.user?.roleId === 3 || authStore.user?.roleId === 4;
const isAdmin = authStore.user?.roleId === 0 || authStore.user?.roleId === 1 || authStore.user?.roleId === 2 || authStore.user?.roleId === 3 || authStore.user?.roleId === 4;
export function NavbarMainMenu({ listNavbar }: { listNavbar: MenuItem[] }) {
const { item, isSearch } = useSnapshot(stateNav)
@@ -46,11 +46,11 @@ export function NavbarMainMenu({ listNavbar }: { listNavbar: MenuItem[] }) {
</Tooltip>
{listNavbar.map((item, k) => (
<MenuItemCom
key={k}
item={item}
isActive={item.href && pathname.startsWith(item.href) ||
(item.children?.some(child => child.href && pathname.startsWith(child.href)))}
<MenuItemCom
key={k}
item={item}
isActive={item.href && pathname.startsWith(item.href) ||
(item.children?.some(child => child.href && pathname.startsWith(child.href)))}
/>
))}
@@ -73,7 +73,7 @@ export function NavbarMainMenu({ listNavbar }: { listNavbar: MenuItem[] }) {
<Tooltip label="Kembali ke Admin" position="bottom" withArrow>
<ActionIcon
onClick={() => {
next.push("/admin/landing-page/profil/program-inovasi")
next.push("/admin/landing-page/profile/program-inovasi")
}}
color={colors["blue-button"]}
radius="xl"

View File

@@ -10,8 +10,7 @@ import {
SimpleGrid,
Skeleton,
Stack,
Text,
useMantineColorScheme
Text
} from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks";
import { Prisma } from "@prisma/client";
@@ -24,8 +23,6 @@ type ProgramInovasiItem = Prisma.ProgramInovasiGetPayload<{ include: { image: tr
function ModuleItem({ data }: { data: ProgramInovasiItem }) {
const router = useTransitionRouter();
const { colorScheme } = useMantineColorScheme();
const isDark = colorScheme === "dark";
return (
<motion.div whileHover={{ scale: 1.03 }}>
@@ -37,7 +34,7 @@ function ModuleItem({ data }: { data: ProgramInovasiItem }) {
role="button"
tabIndex={0}
className="cursor-pointer transition-all"
bg={isDark ? "dark.6" : "white"}
bg="white"
>
<Center h={160}>
{data.image?.link ? (

View File

@@ -1,3 +1,5 @@
"use client";
import colors from "@/con/colors";
import { Box, Space, Stack } from "@mantine/core";
@@ -5,21 +7,20 @@ import { Navbar } from "@/app/darmasaba/_com/Navbar";
import Footer from "./_com/Footer";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<Stack gap={0} bg={colors.grey[1]}>
<Navbar />
<Space h={{
base: "3.9rem",
md: "2.5rem"
}} />
<Box style={{
overflow: "scroll"
}}>
{children}
</Box>
<Footer />
</Stack>
<Stack gap={0} bg={colors.grey[1]}>
<Navbar />
<Space h={{
base: "3.9rem",
md: "2.5rem"
}} />
<Box style={{
overflow: "scroll"
}}>
{children}
</Box>
<Footer />
</Stack>
)
}

View File

@@ -98,16 +98,16 @@ export default function RootLayout({
<html lang="id" {...mantineHtmlProps}>
<head>
<meta charSet="utf-8" />
<ColorSchemeScript />
<ColorSchemeScript defaultColorScheme="light" />
</head>
<body>
<MantineProvider theme={theme}>
<MantineProvider theme={theme} defaultColorScheme="light">
{children}
<LoadDataFirstClient />
<ToastContainer
position="bottom-center"
hideProgressBar
style={{ zIndex: 9999 }}
<ToastContainer
position="bottom-center"
hideProgressBar
style={{ zIndex: 9999 }}
/>
</MantineProvider>
</body>

View File

@@ -0,0 +1,119 @@
'use client';
import { useDarkMode } from '@/state/darkModeStore';
import { themeTokens } from '@/utils/themeTokens';
import { MantineProvider, createTheme } from '@mantine/core';
import '@mantine/core/styles.css';
import '@/styles/dark-mode-table.css';
import React from 'react';
/**
* Admin Theme Provider
*
* Wrapper untuk MantineProvider dengan custom theme
* Mendukung dark mode otomatis
*
* Usage:
* import { AdminThemeProvider } from '@/components/admin/AdminThemeProvider';
*
* <AdminThemeProvider>
* <YourComponent />
* </AdminThemeProvider>
*/
interface AdminThemeProviderProps {
children: React.ReactNode;
forceTheme?: 'light' | 'dark';
}
export function AdminThemeProvider({ children, forceTheme }: AdminThemeProviderProps) {
const { isDark } = useDarkMode();
// Use forced theme if provided, otherwise use store
const useDark = forceTheme ? forceTheme === 'dark' : isDark;
const tokens = themeTokens(useDark);
const theme = createTheme({
colors: {
primary: [
tokens.colors.primaryLight,
tokens.colors.primaryLight,
tokens.colors.primary,
tokens.colors.primary,
tokens.colors.primary,
tokens.colors.primary,
tokens.colors.primaryDark,
tokens.colors.primaryDark,
tokens.colors.primaryDark,
tokens.colors.primaryDark,
],
},
primaryColor: 'primary',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
fontFamilyMonospace: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace',
// Override default colors based on mode
white: tokens.colors.text.inverse,
black: tokens.colors.text.primary,
// CSS variables for table hover
activeClassName: useDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.02)',
// Component defaults
components: {
Paper: {
defaultProps: {
bg: tokens.colors.bg.card,
radius: 'md',
shadow: 'sm',
},
},
Button: {
defaultProps: {
radius: 'md',
},
},
TextInput: {
defaultProps: {
radius: 'md',
},
},
Select: {
defaultProps: {
radius: 'md',
},
},
Modal: {
defaultProps: {
radius: 'lg',
},
},
Table: {
defaultProps: {
highlightOnHover: true,
},
},
},
});
return (
<MantineProvider
theme={theme}
forceColorScheme={useDark ? 'dark' : 'light'}
defaultColorScheme={useDark ? 'dark' : 'light'}
>
<div
style={{
backgroundColor: tokens.colors.bg.main,
color: tokens.colors.text.primary,
minHeight: '100vh',
transition: 'background-color 0.3s ease, color 0.3s ease',
}}
>
{children}
</div>
</MantineProvider>
);
}
export default AdminThemeProvider;

View File

@@ -0,0 +1,78 @@
'use client';
import { useDarkMode } from '@/state/darkModeStore';
import { themeTokens } from '@/utils/themeTokens';
import { ActionIcon, Tooltip, Transition } from '@mantine/core';
import { IconMoon, IconSun } from '@tabler/icons-react';
/**
* Dark Mode Toggle Button
*
* Component untuk toggle dark/light mode
*
* Usage:
* import { DarkModeToggle } from '@/components/admin/DarkModeToggle';
*
* <DarkModeToggle />
*/
interface DarkModeToggleProps {
variant?: 'light' | 'filled' | 'outline' | 'subtle';
size?: 'sm' | 'md' | 'lg';
color?: string;
showTooltip?: boolean;
tooltipPosition?: 'top' | 'bottom' | 'left' | 'right';
}
export function DarkModeToggle({
variant = 'light',
size = 'lg',
color,
showTooltip = true,
tooltipPosition = 'bottom',
}: DarkModeToggleProps) {
const { isDark, toggle } = useDarkMode();
const tokens = themeTokens(isDark);
const iconColor = color || tokens.colors.primary;
return (
<Tooltip
label={isDark ? 'Mode Terang' : 'Mode Gelap'}
position={tooltipPosition}
withArrow
disabled={!showTooltip}
>
<ActionIcon
variant={variant}
size={size}
radius="xl"
onClick={toggle}
color={iconColor}
style={{
transition: 'all 0.3s ease',
transform: 'scale(1)',
'&:hover': {
transform: 'scale(1.1)',
},
}}
>
{/* Icon Sun untuk Light Mode */}
<Transition mounted={!isDark} transition="scale" duration={200}>
{(style) => (
<IconSun style={style} size={20} stroke={1.5} />
)}
</Transition>
{/* Icon Moon untuk Dark Mode */}
<Transition mounted={isDark} transition="scale" duration={200}>
{(style) => (
<IconMoon style={style} size={20} stroke={1.5} />
)}
</Transition>
</ActionIcon>
</Tooltip>
);
}
export default DarkModeToggle;

View File

@@ -0,0 +1,546 @@
# 🎨 Unified Styling System - Admin Dashboard
Sistem styling terpusat untuk admin dashboard Darmasaba dengan dukungan **dark mode**.
**Berdasarkan spesifikasi:** `darkMode.md`
---
## 📋 Daftar Isi
- [Konsep Utama](#konsep-utama)
- [Dark Mode Palette](#dark-mode-palette)
- [Struktur File](#struktur-file)
- [Cara Menggunakan](#cara-menggunakan)
- [Mengedit Style](#mengedit-style)
- [Dark Mode Toggle](#dark-mode-toggle)
- [Contoh Penggunaan](#contoh-penggunaan)
---
## 🎯 Konsep Utama
**Satu File Edit = Semua Halaman Terupdate**
Sebelumnya:
- ❌ Style tersebar di 493 file `.tsx`
- ❌ Hardcode warna di setiap komponen
- ❌ Tidak ada konsistensi
- ❌ Sulit maintain
Sekarang:
- ✅ Edit di **1 file** = semua halaman update
- ✅ Component reusable
- ✅ Konsisten di seluruh aplikasi
- ✅ Dark mode otomatis sesuai spesifikasi `darkMode.md`
---
## 🌙 Dark Mode Palette
### Background Layers (Dark Mode)
| Layer | Token | Warna | Fungsi |
|------|------|------|------|
| Base | `bg.base` | `#0B1220` | Background utama aplikasi |
| App | `bg.app` | `#0F172A` | Area sidebar |
| Card | `bg.card` | `#162235` | Card / container |
| Surface | `bg.surface` | `#1E2A3D` | Table header, tab, input |
### Text Colors (Dark Mode)
| Jenis | Token | Warna |
|-----|------|------|
| Primary | `text.primary` | `#E5E7EB` |
| Secondary | `text.secondary` | `#9CA3AF` |
| Muted | `text.muted` | `#6B7280` |
### Accent & Actions (Dark Mode)
| Fungsi | Warna |
|------|------|
| Primary Action | `#3B82F6` |
| Hover | `#2563EB` |
| Active | `#1D4ED8` |
| Link | `#60A5FA` |
### Borders (Dark Mode)
| Token | Warna |
|-----|------|
| `border.default` | `#2A3A52` |
| `border.soft` | `#22314A` |
> **Catatan:** Light mode menggunakan palette original yang lebih terang
---
## 📁 Struktur File
```
src/
├── utils/
│ └── themeTokens.ts # 📦 PUSAT SEMUA STYLE (edit di sini!)
├── state/
│ └── darkModeStore.ts # 🌙 State management dark mode
├── components/admin/
│ ├── DarkModeToggle.tsx # 🌓 Toggle button
│ ├── AdminThemeProvider.tsx # 🎨 Theme provider wrapper
│ ├── UnifiedTypography.tsx # 📝 Text components (Title, Text)
│ ├── UnifiedSurface.tsx # 📦 Card, Paper components
│ └── README_UNIFIED_STYLING.md # 📖 Dokumentasi ini
├── app/admin/
│ ├── layout.tsx # ✅ Sudah diupdate dengan dark mode
│ └── (dashboard)/
│ └── _com/
│ ├── header.tsx # ✅ Sudah diupdate
│ ├── judulList.tsx # ✅ Sudah diupdate
│ └── judulListTab.tsx # ✅ Sudah diupdate
└── darkMode.md # 📐 Spesifikasi lengkap dark mode
```
---
## 🚀 Cara Menggunakan
### 1. **Untuk Developer: Edit Style Global**
Edit file: `src/utils/themeTokens.ts`
```typescript
export const themeTokens = (isDark: boolean = false): ThemeTokens => {
const darkColors = {
bgBase: '#0B1220', // ← Edit warna dark mode di sini
bgCard: '#162235',
textPrimary: '#E5E7EB',
primaryAction: '#3B82F6',
// ... dan lainnya
};
return {
colors: {
primary: current.primaryAction,
bg: {
base: current.bgBase,
card: current.bgCard,
// ...
},
// ...
},
};
};
```
### 2. **Menggunakan Components di Halaman**
#### A. Typography Components
```tsx
import { UnifiedTitle, UnifiedText } from '@/components/admin/UnifiedTypography';
// Heading - otomatis dark mode
<UnifiedTitle order={1}>Judul Halaman</UnifiedTitle>
<UnifiedTitle order={2}>Sub Judul</UnifiedTitle>
<UnifiedTitle order={3}>Section Title</UnifiedTitle>
<UnifiedTitle order={4}>Card Title</UnifiedTitle>
// Text dengan color semantic
<UnifiedText size="body" color="primary">Teks primary</UnifiedText>
<UnifiedText size="body" color="secondary">Teks secondary</UnifiedText>
<UnifiedText size="body" color="muted">Teks muted</UnifiedText>
<UnifiedText size="body" color="link">Link text</UnifiedText>
<UnifiedText size="body" color="brand">Brand color</UnifiedText>
// Dengan weight
<UnifiedText weight="bold">Teks bold</UnifiedText>
<UnifiedText weight="medium">Teks medium</UnifiedText>
```
#### B. Surface Components
```tsx
import UnifiedCard, { UnifiedDivider } from '@/components/admin/UnifiedSurface';
// Card sederhana - border dan warna otomatis dark mode
<UnifiedCard>
<p>Isi card</p>
</UnifiedCard>
// Card dengan sections
<UnifiedCard>
<UnifiedCard.Header>
<UnifiedTitle order={4}>Header</UnifiedTitle>
</UnifiedCard.Header>
<UnifiedCard.Body>
<p>Body content</p>
</UnifiedCard.Body>
<UnifiedCard.Footer>
<Button>Action</Button>
</UnifiedCard.Footer>
</UnifiedCard>
// Divider dengan variant
<UnifiedDivider variant="soft" /> {/* Default */}
<UnifiedDivider variant="default" />
<UnifiedDivider variant="strong" />
```
#### C. Page Header Component
```tsx
import { UnifiedPageHeader } from '@/components/admin/UnifiedTypography';
<UnifiedPageHeader
title="Daftar Berita"
subtitle="Kelola semua berita di sini"
action={
<Button onClick={handleCreate}>
<IconPlus /> Tambah Baru
</Button>
}
/>
```
### 3. **Menggunakan Theme Tokens Langsung**
```tsx
import { useDarkMode } from '@/state/darkModeStore';
import { themeTokens } from '@/utils/themeTokens';
function MyComponent() {
const { isDark } = useDarkMode();
const tokens = themeTokens(isDark);
return (
<div style={{
backgroundColor: tokens.colors.bg.card,
color: tokens.colors.text.primary,
padding: tokens.spacing.md,
borderRadius: tokens.radius.lg,
border: `1px solid ${tokens.colors.border.default}`,
}}>
<p style={{ fontSize: tokens.typography.body.fz }}>
Konten dengan styling konsisten
</p>
</div>
);
}
```
---
## 🌓 Dark Mode Toggle
### Otomatis di Header
Dark mode toggle sudah terintegrasi di header admin dashboard. User bisa toggle dengan klik tombol 🌙/☀️.
### Manual Toggle
```tsx
import { useDarkMode } from '@/state/darkModeStore';
import { DarkModeToggle } from '@/components/admin/DarkModeToggle';
function MyComponent() {
const { isDark, toggle } = useDarkMode();
return (
<div>
<p>Current mode: {isDark ? 'Dark' : 'Light'}</p>
{/* Gunakan component toggle */}
<DarkModeToggle />
{/* Atau manual */}
<button onClick={toggle}>Toggle</button>
</div>
);
}
```
### Persistensi
Dark mode preference disimpan di `localStorage` dengan key `darmasaba-admin-dark-mode`.
Preference akan tetap ada saat user refresh halaman atau kembali nanti.
---
## 📝 Contoh Penggunaan Lengkap
### Contoh 1: List Page dengan Table
```tsx
'use client'
import { UnifiedPageHeader, UnifiedText } from '@/components/admin/UnifiedTypography';
import UnifiedCard from '@/components/admin/UnifiedSurface';
import { useDarkMode } from '@/state/darkModeStore';
import { themeTokens } from '@/utils/themeTokens';
import { Button, Table, TableTr, TableTh, TableTd } from '@mantine/core';
export default function DaftarBerita() {
const { isDark } = useDarkMode();
const tokens = themeTokens(isDark);
return (
<div>
{/* Header Halaman */}
<UnifiedPageHeader
title="Daftar Berita"
subtitle="Kelola semua berita yang diterbitkan"
action={
<Button
bg={tokens.colors.primary}
style={{
background: `linear-gradient(135deg, ${tokens.colors.primary}, ${isDark ? '#60A5FA' : '#4facfe'})`,
}}
>
+ Tambah Berita
</Button>
}
/>
{/* Card untuk Table */}
<UnifiedCard>
<Table>
<TableThead>
<TableTr>
<TableTh style={{ backgroundColor: tokens.colors.bg.surface }}>
<UnifiedText size="label" color="secondary">Judul</UnifiedText>
</TableTh>
<TableTh style={{ backgroundColor: tokens.colors.bg.surface }}>
<UnifiedText size="label" color="secondary">Kategori</UnifiedText>
</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{data.map((item) => (
<TableTr
key={item.id}
style={{
'&:hover': {
backgroundColor: tokens.colors.bg.hover,
},
}}
>
<TableTd>
<UnifiedText size="body">{item.judul}</UnifiedText>
</TableTd>
<TableTd>
<UnifiedText size="small" color="secondary">
{item.kategori}
</UnifiedText>
</TableTd>
</TableTr>
))}
</TableTbody>
</Table>
</UnifiedCard>
</div>
);
}
```
### Contoh 2: Detail Page
```tsx
import { UnifiedTitle, UnifiedText } from '@/components/admin/UnifiedTypography';
import UnifiedCard, { UnifiedDivider } from '@/components/admin/UnifiedSurface';
export default function DetailBerita({ data }) {
return (
<UnifiedCard>
<UnifiedCard.Header>
<UnifiedTitle order={3} color="brand">{data.judul}</UnifiedTitle>
</UnifiedCard.Header>
<UnifiedCard.Body>
<Box mb="md">
<UnifiedText size="label" color="muted">Kategori</UnifiedText>
<UnifiedText size="body" weight="medium">{data.kategori}</UnifiedText>
</Box>
<UnifiedDivider variant="soft" />
<Box my="md">
<UnifiedText size="label" color="muted">Deskripsi</UnifiedText>
<UnifiedText size="body">{data.deskripsi}</UnifiedText>
</Box>
<UnifiedDivider variant="soft" />
<Box mt="md">
<UnifiedText size="label" color="muted">Konten</UnifiedText>
<div dangerouslySetInnerHTML={{ __html: data.content }} />
</Box>
</UnifiedCard.Body>
<UnifiedCard.Footer>
<Group justify="right">
<Button color="red">Hapus</Button>
<Button color="green">Edit</Button>
</Group>
</UnifiedCard.Footer>
</UnifiedCard>
);
}
```
---
## 🎨 Mengedit Style
### Edit Warna Dark Mode
File: `src/utils/themeTokens.ts`
```typescript
const darkColors = {
// Background Layers
bgBase: '#0B1220', // ← Edit di sini
bgApp: '#0F172A',
bgCard: '#162235',
bgSurface: '#1E2A3D',
// Text
textPrimary: '#E5E7EB', // ← Edit di sini
textSecondary: '#9CA3AF',
// Accent
primaryAction: '#3B82F6', // ← Edit primary color
};
```
### Edit Warna Light Mode
```typescript
const lightColors = {
bgBase: '#f6f9fc',
bgCard: '#ffffff',
textPrimary: '#1a1b1e',
primaryAction: baseColors['blue-button'], // Dari colors.ts
};
```
### Edit Typography
```typescript
typography: {
h1: {
fz: '2rem', // ← Edit ukuran
fw: 700, // ← Edit weight
lh: 1.2, // ← Edit line height
},
body: {
fz: '1rem',
fw: 400,
lh: 1.5,
},
}
```
### Edit Spacing & Radius
```typescript
spacing: {
xs: '0.625rem', // 10px
sm: '1rem', // 16px
md: '1.5rem', // 24px
lg: '2rem', // 32px
}
radius: {
sm: '0.5rem', // 8px
md: '0.75rem', // 12px
lg: '1rem', // 16px
}
```
---
## ✅ Checklist Migrasi
Komponen yang sudah diupdate dengan dark mode:
-`src/app/admin/layout.tsx`
-`src/app/admin/(dashboard)/_com/header.tsx`
-`src/app/admin/(dashboard)/_com/judulList.tsx`
-`src/app/admin/(dashboard)/_com/judulListTab.tsx`
-`src/components/admin/UnifiedTypography.tsx`
-`src/components/admin/UnifiedSurface.tsx`
-`src/components/admin/DarkModeToggle.tsx`
-`src/utils/themeTokens.ts`
Komponen yang perlu diupdate (TODO):
- [ ] Komponen di `src/app/admin/(dashboard)/desa/`
- [ ] Komponen di `src/app/admin/(dashboard)/ppid/`
- [ ] Komponen di `src/app/admin/(dashboard)/kesehatan/`
- [ ] Komponen di `src/app/admin/(dashboard)/pendidikan/`
- [ ] Komponen di `src/app/admin/(dashboard)/ekonomi/`
- [ ] Dan lain-lain...
---
## 📚 Referensi
- [Dark Mode Specification](../../../darkMode.md) - Spesifikasi lengkap dark mode
- [Mantine Theme System](https://mantine.dev/theming/theme-object/)
- [Mantine Dark Mode](https://mantine.dev/theming/dark-mode/)
- [Valtio State Management](https://github.com/pmndrs/valtio)
---
## 💡 Tips
1. **Selalu gunakan unified components** untuk konsistensi dark/light mode
2. **Edit di `themeTokens.ts`** untuk perubahan global
3. **Test dark mode** setelah perubahan style
4. **Gunakan color semantic** (`primary`, `secondary`, `muted`) bukan hex langsung
5. **Jangan hardcode shadow** di dark mode (spec: "Jangan pakai shadow hitam")
6. **Border harus terlihat** di dark mode (opacity > 20%)
---
## 🆘 Troubleshooting
### Style tidak berubah setelah edit themeTokens.ts?
1. Clear browser cache (Cmd+Shift+R / Ctrl+Shift+R)
2. Restart dev server: `bun run dev`
3. Pastikan komponen menggunakan unified components
### Dark mode tidak berfungsi?
1. Cek `darkModeStore.ts` sudah diimport
2. Pastikan `useDarkMode()` hook digunakan
3. Clear localStorage: `localStorage.clear()`
4. Cek console untuk error
### Border tidak terlihat di dark mode?
Pastikan menggunakan `tokens.colors.border.default` atau `tokens.colors.border.soft`, bukan hardcode warna.
### Component tidak re-render?
1. Pastikan `'use client'` ada di file component
2. Gunakan `useSnapshot()` jika menggunakan Valtio di non-event handler
3. Cek console untuk error
---
## 📐 Spesifikasi Dark Mode
Untuk spesifikasi lengkap dark mode (layout rules, table styles, button rules, dll), lihat:
**[`darkMode.md`](../../../darkMode.md)**
Highlights:
- ✅ Background layers berbeda (base, app, card, surface)
- ✅ Border wajib terlihat (tidak flat)
- ✅ Active state dengan accent bar (2-3px)
- ✅ Tidak pakai shadow hitam
- ✅ Hover state dengan background soft
- ✅ Text kontras terbaca
---
**Last Updated:** February 20, 2026
**Version:** 2.0.0 (Dark Mode Ready)
**Based on:** darkMode.md specification

View File

@@ -0,0 +1,252 @@
'use client';
import { useDarkMode } from '@/state/darkModeStore';
import { themeTokens } from '@/utils/themeTokens';
import { Paper, Box, BoxProps, Divider, DividerProps } from '@mantine/core';
import React from 'react';
/**
* Unified Surface Components
*
* Komponen container/card dengan styling konsisten
* Mendukung dark mode sesuai spesifikasi darkMode.md
*
* Usage:
* import { UnifiedCard, UnifiedDivider } from '@/components/admin/UnifiedSurface';
*
* <UnifiedCard>
* <UnifiedCard.Header>Title</UnifiedCard.Header>
* <UnifiedCard.Body>Content</UnifiedCard.Body>
* </UnifiedCard>
*/
// ============================================================================
// Unified Card Component
* ============================================================================
interface UnifiedCardProps extends BoxProps {
withBorder?: boolean;
shadow?: 'none' | 'sm' | 'md' | 'lg';
padding?: 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
hoverable?: boolean;
children: React.ReactNode;
}
export function UnifiedCard({
withBorder = true,
shadow = 'none', // Sesuai spec: Jangan pakai shadow hitam
padding = 'md',
hoverable = false,
children,
style,
...props
}: UnifiedCardProps) {
const { isDark } = useDarkMode();
const tokens = themeTokens(isDark);
const getPadding = () => {
switch (padding) {
case 'none':
return 0;
case 'xs':
return tokens.spacing.xs;
case 'sm':
return tokens.spacing.sm;
case 'md':
return tokens.spacing.md;
case 'lg':
return tokens.spacing.lg;
case 'xl':
return tokens.spacing.xl;
default:
return tokens.spacing.md;
}
};
return (
<Paper
withBorder={withBorder}
bg={tokens.colors.bg.card}
p={getPadding()}
radius={tokens.radius.lg} // 12-16px sesuai spec
style={{
borderColor: tokens.colors.border.default,
transition: hoverable
? 'transform 0.2s ease, box-shadow 0.2s ease'
: 'box-shadow 0.2s ease',
'&:hover': hoverable
? {
transform: 'translateY(-2px)',
}
: {},
...style,
}}
{...props}
>
{children}
</Paper>
);
}
// ============================================================================
// Unified Card Section Components
// ============================================================================
interface UnifiedCardSectionProps {
children: React.ReactNode;
padding?: 'none' | 'xs' | 'sm' | 'md' | 'lg';
border?: 'none' | 'top' | 'bottom';
style?: React.CSSProperties;
}
UnifiedCard.Header = function UnifiedCardHeader({
children,
padding = 'md',
border = 'bottom',
style,
}: UnifiedCardSectionProps) {
const { isDark } = useDarkMode();
const tokens = themeTokens(isDark);
const getPadding = () => {
switch (padding) {
case 'none':
return 0;
case 'xs':
return tokens.spacing.xs;
case 'sm':
return tokens.spacing.sm;
case 'md':
return tokens.spacing.md;
case 'lg':
return tokens.spacing.lg;
default:
return tokens.spacing.md;
}
};
const borderBottom = border === 'bottom' ? `1px solid ${tokens.colors.border.soft}` : 'none';
const borderTop = border === 'top' ? `1px solid ${tokens.colors.border.soft}` : 'none';
return (
<Box
style={{
paddingBottom: getPadding(),
borderBottom,
borderTop,
...style,
}}
>
{children}
</Box>
);
};
UnifiedCard.Body = function UnifiedCardBody({
children,
padding = 'md',
style,
}: UnifiedCardSectionProps) {
const { isDark } = useDarkMode();
const tokens = themeTokens(isDark);
const getPadding = () => {
switch (padding) {
case 'none':
return 0;
case 'xs':
return tokens.spacing.xs;
case 'sm':
return tokens.spacing.sm;
case 'md':
return tokens.spacing.md;
case 'lg':
return tokens.spacing.lg;
default:
return tokens.spacing.md;
}
};
return (
<Box style={{ paddingTop: getPadding(), paddingBottom: getPadding(), ...style }}>
{children}
</Box>
);
};
UnifiedCard.Footer = function UnifiedCardFooter({
children,
padding = 'md',
border = 'top',
style,
}: UnifiedCardSectionProps) {
const { isDark } = useDarkMode();
const tokens = themeTokens(isDark);
const getPadding = () => {
switch (padding) {
case 'none':
return 0;
case 'xs':
return tokens.spacing.xs;
case 'sm':
return tokens.spacing.sm;
case 'md':
return tokens.spacing.md;
case 'lg':
return tokens.spacing.lg;
default:
return tokens.spacing.md;
}
};
const borderBottom = border === 'bottom' ? `1px solid ${tokens.colors.border.soft}` : 'none';
const borderTop = border === 'top' ? `1px solid ${tokens.colors.border.soft}` : 'none';
return (
<Box
style={{
paddingTop: getPadding(),
borderBottom,
borderTop,
...style,
}}
>
{children}
</Box>
);
};
// ============================================================================
// Unified Divider Component
// ============================================================================
interface UnifiedDividerProps extends DividerProps {
variant?: 'default' | 'soft' | 'strong';
}
export function UnifiedDivider({
variant = 'soft', // Default soft sesuai spec
my = 'md',
...props
}: UnifiedDividerProps) {
const { isDark } = useDarkMode();
const tokens = themeTokens(isDark);
const getColor = () => {
switch (variant) {
case 'default':
return tokens.colors.border.default;
case 'soft':
return tokens.colors.border.soft;
case 'strong':
return tokens.colors.border.strong;
default:
return tokens.colors.border.soft;
}
};
return <Divider my={my} color={getColor()} {...props} />;
}
export default UnifiedCard;

View File

@@ -0,0 +1,268 @@
'use client';
import { useDarkMode } from '@/state/darkModeStore';
import { themeTokens, getResponsiveFz } from '@/utils/themeTokens';
import { Text, Title, Box, BoxProps } from '@mantine/core';
import React from 'react';
/**
* Unified Typography Components
*
* Komponen text dengan styling konsisten di seluruh aplikasi
* Mendukung dark mode sesuai spesifikasi darkMode.md
*
* Usage:
* import { UnifiedText, UnifiedTitle } from '@/components/admin/UnifiedTypography';
*
* <UnifiedTitle order={1}>Judul Halaman</UnifiedTitle>
* <UnifiedText size="body">Konten teks</UnifiedText>
*/
// ============================================================================
// Unified Title Component
// ============================================================================
interface UnifiedTitleProps {
order?: 1 | 2 | 3 | 4 | 5 | 6;
children: React.ReactNode;
align?: 'left' | 'center' | 'right';
color?: 'primary' | 'secondary' | 'brand' | string;
mb?: string;
mt?: string;
ml?: string;
mr?: string;
mx?: string;
my?: string;
style?: React.CSSProperties;
}
export function UnifiedTitle({
order = 1,
children,
align = 'left',
color = 'primary',
mb,
mt,
ml,
mr,
mx,
my,
style,
}: UnifiedTitleProps) {
const { isDark } = useDarkMode();
const tokens = themeTokens(isDark);
const responsiveFz = getResponsiveFz(isDark);
const getTypography = () => {
switch (order) {
case 1:
return tokens.typography.h1;
case 2:
return tokens.typography.h2;
case 3:
return tokens.typography.h3;
case 4:
return tokens.typography.h4;
default:
return tokens.typography.body;
}
};
const typo = getTypography();
const getColor = () => {
if (color === 'primary') return tokens.colors.text.primary;
if (color === 'secondary') return tokens.colors.text.secondary;
if (color === 'brand') return tokens.colors.brand;
return color;
};
return (
<Title
order={order}
ta={align}
fz={{ base: responsiveFz.base, md: typo.fz }}
fw={typo.fw}
lh={typo.lh}
c={getColor()}
mb={mb}
mt={mt}
ml={ml}
mr={mr}
mx={mx}
my={my}
style={style}
>
{children}
</Title>
);
}
// ============================================================================
// Unified Text Component
// ============================================================================
interface UnifiedTextProps {
size?: 'small' | 'body' | 'label';
weight?: 'normal' | 'medium' | 'bold';
children: React.ReactNode;
align?: 'left' | 'center' | 'right';
color?: 'primary' | 'secondary' | 'tertiary' | 'muted' | 'brand' | 'link' | string;
lineClamp?: number;
truncate?: 'start' | 'end' | 'middle' | boolean;
span?: boolean;
style?: React.CSSProperties;
}
export function UnifiedText({
size = 'body',
weight = 'normal',
children,
align = 'left',
color = 'primary',
lineClamp,
truncate,
span = false,
style,
}: UnifiedTextProps) {
const { isDark } = useDarkMode();
const tokens = themeTokens(isDark);
const getTypography = () => {
switch (size) {
case 'small':
return tokens.typography.small;
case 'label':
return tokens.typography.label;
default:
return tokens.typography.body;
}
};
const getWeight = () => {
switch (weight) {
case 'normal':
return 400;
case 'medium':
return 500;
case 'bold':
return 700;
default:
return 400;
}
};
const getColor = () => {
switch (color) {
case 'primary':
return tokens.colors.text.primary;
case 'secondary':
return tokens.colors.text.secondary;
case 'tertiary':
return tokens.colors.text.tertiary;
case 'muted':
return tokens.colors.text.muted;
case 'brand':
return tokens.colors.brand;
case 'link':
return tokens.colors.text.link;
default:
return color;
}
};
const typo = getTypography();
const fw = getWeight();
const textColor = getColor();
if (span) {
return (
<Text.Span
ta={align}
fz={typo.fz}
fw={fw}
lh={typo.lh}
c={textColor}
lineClamp={lineClamp}
truncate={truncate}
style={style}
>
{children}
</Text.Span>
);
}
return (
<Text
ta={align}
fz={typo.fz}
fw={fw}
lh={typo.lh}
c={textColor}
lineClamp={lineClamp}
truncate={truncate}
style={style}
>
{children}
</Text>
);
}
// ============================================================================
// Unified Page Header Component
//
// Header standar untuk setiap halaman admin
// Sesuai spesifikasi: Section Header dengan font weight lebih besar
// ============================================================================
interface UnifiedPageHeaderProps extends BoxProps {
title: string;
subtitle?: string;
action?: React.ReactNode;
showBorder?: boolean;
}
export function UnifiedPageHeader({
title,
subtitle,
action,
showBorder = true,
style,
...props
}: UnifiedPageHeaderProps) {
const { isDark } = useDarkMode();
const tokens = themeTokens(isDark);
return (
<Box
mb="lg"
style={{
borderBottom: showBorder ? `1px solid ${tokens.colors.border.soft}` : 'none',
paddingBottom: showBorder ? tokens.spacing.md : 0,
...style,
}}
{...props}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: tokens.spacing.md,
}}
>
<div>
<UnifiedTitle order={3} color="primary">{title}</UnifiedTitle>
{subtitle && (
<UnifiedText size="small" color="secondary" mt="xs">
{subtitle}
</UnifiedText>
)}
</div>
{action && <div>{action}</div>}
</div>
</Box>
);
}
export default UnifiedText;

84
src/lib/api-auth.ts Normal file
View File

@@ -0,0 +1,84 @@
/**
* Authentication helper untuk API endpoints
*
* Usage:
* import { requireAuth } from "@/lib/api-auth";
*
* export default async function myEndpoint(context: Context) {
* const authResult = await requireAuth(context);
* if (!authResult.authenticated) {
* return authResult.response;
* }
* // Lanjut proses dengan authResult.user
* }
*/
import { getSession } from "@/lib/session";
export type AuthResult =
| { authenticated: true; user: any }
| { authenticated: false; response: Response };
export async function requireAuth(context: any): Promise<AuthResult> {
try {
// Cek session dari cookies
const session = await getSession();
if (!session || !session.user) {
return {
authenticated: false,
response: new Response(JSON.stringify({
success: false,
message: "Unauthorized - Silakan login terlebih dahulu"
}), {
status: 401,
headers: { 'Content-Type': 'application/json' }
})
};
}
// Check jika user masih aktif
if (!session.user.isActive) {
return {
authenticated: false,
response: new Response(JSON.stringify({
success: false,
message: "Akun Anda tidak aktif. Hubungi administrator."
}), {
status: 403,
headers: { 'Content-Type': 'application/json' }
})
};
}
return {
authenticated: true,
user: session.user
};
} catch (error) {
console.error("Auth error:", error);
return {
authenticated: false,
response: new Response(JSON.stringify({
success: false,
message: "Authentication error"
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
})
};
}
}
/**
* Optional auth - tidak error jika tidak authenticated
* Berguna untuk endpoint yang bisa diakses public atau private
*/
export async function optionalAuth(context: any): Promise<any> {
try {
const session = await getSession();
return session?.user || null;
} catch (error) {
return null;
}
}

68
src/lib/session.ts Normal file
View File

@@ -0,0 +1,68 @@
/**
* Session helper menggunakan iron-session
*
* Usage:
* import { getSession } from "@/lib/session";
*
* const session = await getSession();
* if (session?.user) {
* // User authenticated
* }
*/
import { getIronSession } from 'iron-session';
import { cookies } from 'next/headers';
export type SessionData = {
user?: {
id: string;
name: string;
roleId: number;
menuIds?: string[] | null;
isActive?: boolean;
};
};
export type Session = SessionData & {
save: () => Promise<void>;
destroy: () => Promise<void>;
};
const SESSION_OPTIONS = {
cookieName: 'desa-session',
password: process.env.SESSION_PASSWORD || 'default-password-change-in-production',
cookieOptions: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
sameSite: 'lax' as const,
maxAge: 60 * 60 * 24 * 7, // 7 days
},
};
export async function getSession(): Promise<SessionData | null> {
try {
const cookieStore = await cookies();
const session = await getIronSession<SessionData>(
cookieStore,
SESSION_OPTIONS
);
return session;
} catch (error) {
console.error('Session error:', error);
return null;
}
}
export async function destroySession(): Promise<void> {
try {
const cookieStore = await cookies();
const session = await getIronSession<SessionData>(
cookieStore,
SESSION_OPTIONS
);
await session.destroy();
} catch (error) {
console.error('Destroy session error:', error);
}
}

View File

@@ -0,0 +1,77 @@
/**
* Dark Mode State Management
*
* Menggunakan Valtio untuk global state
* Persist ke localStorage
*
* Usage:
* import { darkModeStore } from '@/state/darkModeStore';
*
* // Toggle
* darkModeStore.toggle();
*
* // Set explicitly
* darkModeStore.setDarkMode(true);
*
* // Get current state
* const isDark = darkModeStore.isDark;
*/
import { proxy, useSnapshot } from 'valtio';
const STORAGE_KEY = 'darmasaba-admin-dark-mode';
// Initialize from localStorage or default to light mode
const getInitialDarkMode = (): boolean => {
if (typeof window === 'undefined') return false;
const stored = localStorage.getItem(STORAGE_KEY);
if (stored !== null) {
return stored === 'true';
}
// Default to light mode for first-time users
// System preference is NOT used as default to ensure consistent UX
return false;
};
class DarkModeStore {
public isDark: boolean;
constructor() {
this.isDark = getInitialDarkMode();
}
public toggle() {
this.isDark = !this.isDark;
this.persist();
}
public setDarkMode(value: boolean) {
this.isDark = value;
this.persist();
}
private persist() {
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, String(this.isDark));
}
}
}
// Create proxy instance
const store = new DarkModeStore();
export const darkModeStore = proxy(store);
// Hook untuk menggunakan dark mode state di React components
export const useDarkMode = () => {
const snapshot = useSnapshot(darkModeStore);
return {
isDark: snapshot.isDark,
toggle: () => darkModeStore.toggle(),
setDarkMode: (value: boolean) => darkModeStore.setDarkMode(value),
};
};
export default darkModeStore;

View File

@@ -0,0 +1,31 @@
/**
* Dark Mode Table Styles
*
* Override Mantine table hover styles untuk dark mode
* Agar teks putih tetap terlihat saat hover
*/
/* Dark mode table hover */
[data-mantine-color-scheme="dark"] {
/* Table hover */
.mantine-Table-tr:hover {
background-color: rgba(255, 255, 255, 0.08) !important;
}
/* Table striped hover */
.mantine-Table-striped .mantine-Table-tr:nth-of-type(odd):hover {
background-color: rgba(255, 255, 255, 0.08) !important;
}
/* Table with column borders */
.mantine-Table-withColumnBorders .mantine-Table-tr:hover {
background-color: rgba(255, 255, 255, 0.08) !important;
}
}
/* Light mode table hover - default Mantine behavior */
[data-mantine-color-scheme="light"] {
.mantine-Table-tr:hover {
background-color: rgba(0, 0, 0, 0.02) !important;
}
}

383
src/utils/themeTokens.ts Normal file
View File

@@ -0,0 +1,383 @@
/**
* Unified Theme Tokens for Admin Dashboard
*
* Berdasarkan spesifikasi: darkMode.md
*
* Semua styling constants disimpan di sini untuk konsistensi
* Edit di sini = edit di seluruh aplikasi
*
* Usage:
* import { themeTokens } from '@/utils/themeTokens';
*
* // Light mode (default)
* const tokens = themeTokens(false);
*
* // Dark mode
* const tokens = themeTokens(true);
*/
export type ThemeTokens = {
// Colors
colors: {
primary: string;
primaryLight: string;
primaryDark: string;
gradient: {
from: string;
to: string;
};
// Backgrounds
bg: {
base: string;
main: string;
app: string;
surface: string;
surfaceElevated: string;
header: string;
navbar: string;
card: string;
hover: string;
tableHeader: string;
tableHover: string;
};
// Text
text: {
primary: string;
secondary: string;
tertiary: string;
muted: string;
brand: string;
inverse: string;
link: string;
};
// Borders
border: {
default: string;
soft: string;
strong: string;
};
// Status
success: string;
warning: string;
error: string;
info: string;
};
// Typography
typography: {
h1: {
fz: string;
fw: number;
lh: number;
};
h2: {
fz: string;
fw: number;
lh: number;
};
h3: {
fz: string;
fw: number;
lh: number;
};
h4: {
fz: string;
fw: number;
lh: number;
};
body: {
fz: string;
fw: number;
lh: number;
};
small: {
fz: string;
fw: number;
lh: number;
};
label: {
fz: string;
fw: number;
lh: number;
};
};
// Spacing
spacing: {
xs: string;
sm: string;
md: string;
lg: string;
xl: string;
};
// Border Radius
radius: {
sm: string;
md: string;
lg: string;
xl: string;
};
// Shadows
shadows: {
none: string;
sm: string;
md: string;
lg: string;
};
// Layout
layout: {
headerHeight: number;
navbarWidth: {
base: number;
sm: number;
lg: number;
};
};
};
export const themeTokens = (isDark: boolean = false): ThemeTokens => {
// Base colors - tetap menggunakan colors.ts sebagai base untuk light mode
const baseColors = {
'orange': '#FCAE00',
'blue-button': '#0A4E78',
'blue-button-1': '#E5F2FA',
'blue-button-2': '#B8DAEF',
'blue-button-3': '#8AC1E3',
'blue-button-4': '#5DA9D8',
'blue-button-5': '#2F91CC',
'blue-button-6': '#083F61',
'blue-button-7': '#062F49',
'blue-button-8': '#041F32',
'blue-button-trans': '#628EC6',
'white-1': '#FBFBFC',
'white-trans-1': 'rgba(255, 255, 255, 0.5)',
'white-trans-2': 'rgba(255, 255, 255, 0.7)',
'white-trans-3': 'rgba(255, 255, 255, 0.9)',
'grey-1': '#F4F5F6',
'grey-2': '#CBCACD',
'Bg': '#D1d9e8',
'BG-trans': '#B1C5F2',
};
/**
* DARK MODE PALETTE
* Berdasarkan spesifikasi: darkMode.md
*/
const darkColors = {
// Background Layers
bgBase: '#0B1220',
bgApp: '#0F172A',
bgCard: '#162235',
bgSurface: '#1E2A3D',
// Borders
borderDefault: '#2A3A52',
borderSoft: '#22314A',
// Text
textPrimary: '#E5E7EB',
textSecondary: '#9CA3AF',
textMuted: '#6B7280',
textInverse: '#020617',
// Accent & Actions
primaryAction: '#3B82F6',
primaryHover: '#2563EB',
primaryActive: '#1D4ED8',
link: '#60A5FA',
// Status
success: '#22C55E',
warning: '#FACC15',
error: '#EF4444',
info: '#38BDF8',
// Hover states
hoverSoft: 'rgba(255,255,255,0.03)',
hoverMedium: 'rgba(255,255,255,0.04)',
activeAccent: 'rgba(59,130,246,0.15)',
};
/**
* LIGHT MODE PALETTE
* Original light theme
*/
const lightColors = {
bgBase: '#f6f9fc',
bgApp: '#ffffff',
bgCard: '#ffffff',
bgSurface: '#f8fafc',
borderDefault: '#e2e8f0',
borderSoft: '#e9ecef',
textPrimary: '#1a1b1e',
textSecondary: '#495057',
textMuted: '#868e96',
textInverse: '#ffffff',
primaryAction: baseColors['blue-button'],
primaryHover: '#083F61',
primaryActive: '#062F49',
link: '#2563eb',
hoverSoft: 'rgba(25, 113, 194, 0.03)',
hoverMedium: 'rgba(25, 113, 194, 0.05)',
activeAccent: 'rgba(25, 113, 194, 0.1)',
};
const current = isDark ? darkColors : lightColors;
return {
colors: {
primary: current.primaryAction,
primaryLight: isDark ? current.activeAccent : baseColors['blue-button-1'],
primaryDark: current.primaryActive,
gradient: {
from: current.primaryAction,
to: isDark ? '#60A5FA' : '#228be6',
},
bg: {
base: current.bgBase,
main: isDark ? current.bgBase : 'linear-gradient(180deg, #fdfdfd, #f6f9fc)',
app: current.bgApp,
surface: current.bgSurface,
surfaceElevated: isDark ? '#253347' : '#ffffff',
header: isDark
? `linear-gradient(180deg, ${current.bgApp} 0%, ${current.bgBase} 100%)`
: 'linear-gradient(90deg, #ffffff, #f9fbff)',
navbar: current.bgApp,
card: current.bgCard,
hover: current.hoverMedium,
tableHeader: current.bgSurface,
tableHover: isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.02)',
},
text: {
primary: current.textPrimary,
secondary: current.textSecondary,
tertiary: current.textMuted,
muted: current.textMuted,
brand: current.primaryAction,
inverse: current.textInverse,
link: current.link,
},
border: {
default: current.borderDefault,
soft: current.borderSoft,
strong: isDark ? '#3A4A62' : '#ced4da',
},
success: current.success,
warning: current.warning,
error: current.error,
info: current.info,
},
typography: {
h1: {
fz: isDark ? '2rem' : '2.25rem',
fw: 700,
lh: 1.2,
},
h2: {
fz: isDark ? '1.75rem' : '2rem',
fw: 700,
lh: 1.25,
},
h3: {
fz: isDark ? '1.5rem' : '1.75rem',
fw: 700,
lh: 1.3,
},
h4: {
fz: isDark ? '1.25rem' : '1.5rem',
fw: 600,
lh: 1.35,
},
body: {
fz: '1rem',
fw: 400,
lh: 1.5,
},
small: {
fz: '0.875rem',
fw: 400,
lh: 1.4,
},
label: {
fz: '0.75rem',
fw: 600,
lh: 1.4,
},
},
spacing: {
xs: '0.625rem',
sm: '1rem',
md: '1.5rem',
lg: '2rem',
xl: '2.5rem',
},
radius: {
sm: '0.5rem', // 8px
md: '0.75rem', // 12px
lg: '1rem', // 16px
xl: '1.25rem', // 20px
},
shadows: {
none: 'none',
sm: isDark ? '0 1px 3px rgba(0,0,0,0.3)' : '0 1px 3px rgba(0,0,0,0.1)',
md: isDark ? '0 4px 6px rgba(0,0,0,0.3)' : '0 4px 6px rgba(0,0,0,0.1)',
lg: isDark ? '0 10px 15px rgba(0,0,0,0.3)' : '0 10px 15px rgba(0,0,0,0.1)',
},
layout: {
headerHeight: 64,
navbarWidth: {
base: 260,
sm: 280,
lg: 300,
},
},
};
};
// Export default theme instances
export const lightTheme = themeTokens(false);
export const darkTheme = themeTokens(true);
// Helper untuk mendapatkan responsive font size
export const getResponsiveFz = (isDark: boolean = false) => ({
base: isDark ? 'md' : 'lg',
md: isDark ? 'lg' : 'xl',
});
// Helper untuk mendapatkan color berdasarkan state
export const getActiveColor = (isActive: boolean, isDark: boolean = false) =>
isActive ? themeTokens(isDark).colors.primary : isDark ? themeTokens(isDark).colors.text.secondary : 'gray';
// Helper untuk mendapatkan background hover
export const getHoverBackground = (isActive: boolean, isDark: boolean = false) => {
const tokens = themeTokens(isDark);
return isActive
? tokens.colors.bg.hover
: tokens.colors.bg.hover;
};
// Helper untuk active state dengan accent bar (sidebar)
export const getActiveStateStyles = (isActive: boolean, isDark: boolean = false) => {
const tokens = themeTokens(isDark);
if (isActive) {
return {
backgroundColor: isDark ? tokens.colors.bg.hover : 'rgba(25, 113, 194, 0.1)',
borderLeft: isDark ? `3px solid ${tokens.colors.primary}` : '3px solid #1971c2',
};
}
return {
'&:hover': {
backgroundColor: tokens.colors.bg.hover,
},
};
};