Compare commits

...

3 Commits

Author SHA1 Message Date
87eb501661 Merge pull request 'nico/23-feb-26/dark-mode-implementation' (#67) from nico/23-feb-26/dark-mode-implementation into nico/fix-page
Reviewed-on: #67
2026-02-25 10:17:15 +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
73 changed files with 3160 additions and 170 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

@@ -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

@@ -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,104 @@ 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: tokens.colors.bg.hover,
}),
}
}}
active={isChildActive}
variant="subtle"
component={Link}
/>
);
@@ -282,7 +372,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 +380,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

@@ -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

@@ -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;

View File

@@ -0,0 +1,76 @@
/**
* 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 system preference
const getInitialDarkMode = (): boolean => {
if (typeof window === 'undefined') return false;
const stored = localStorage.getItem(STORAGE_KEY);
if (stored !== null) {
return stored === 'true';
}
// Fallback to system preference
return window.matchMedia('(prefers-color-scheme: dark)').matches;
};
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,
},
};
};