Compare commits

..

8 Commits

42 changed files with 668 additions and 581 deletions

View File

@@ -18,6 +18,7 @@ export default function Registrasi() {
const [username, setUsername] = useState('');
const [loading, setLoading] = useState(false);
const [phone, setPhone] = useState(''); // ✅ tambahkan state untuk phone
const [agree, setAgree] = useState(false)
// Ambil data dari localStorage (dari login)
useEffect(() => {
@@ -46,6 +47,11 @@ export default function Registrasi() {
return;
}
if (!agree) {
toast.error("Anda harus menyetujui syarat dan ketentuan!");
return;
}
try {
setLoading(true);
// ✅ Hanya kirim username & nomor → dapat kodeId
@@ -92,8 +98,8 @@ export default function Registrasi() {
username.length > 0 && username.length < 5
? 'Minimal 5 karakter!'
: username.includes(' ')
? 'Tidak boleh ada spasi'
: ''
? 'Tidak boleh ada spasi'
: ''
}
required
/>
@@ -108,9 +114,29 @@ export default function Registrasi() {
</Box>
<Box pt="md">
<Checkbox label="Saya menyetujui syarat dan ketentuan" defaultChecked />
<Checkbox
checked={agree}
onChange={(e) => setAgree(e.currentTarget.checked)}
label={
<Text fz="sm">
Saya menyetujui{" "}
<a
href="/terms-of-service"
target="_blank"
style={{
color: colors["blue-button"],
textDecoration: "underline",
fontWeight: 500,
}}
>
syarat dan ketentuan
</a>
</Text>
}
/>
</Box>
<Box pt="xl">
<Button
fullWidth

View File

@@ -31,7 +31,7 @@ export default function Validasi() {
useEffect(() => {
const checkFlow = async () => {
try {
const res = await fetch('/api/get-flow', {
const res = await fetch('/api/auth/get-flow', {
credentials: 'include'
});
const data = await res.json();
@@ -60,7 +60,7 @@ export default function Validasi() {
setKodeId(storedKodeId);
const loadOtpData = async () => {
try {
const res = await fetch(`/api/otp-data?kodeId=${encodeURIComponent(storedKodeId)}`);
const res = await fetch(`/api/auth/otp-data?kodeId=${encodeURIComponent(storedKodeId)}`);
const result = await res.json();
if (res.ok && result.data?.nomor) {
@@ -124,6 +124,7 @@ export default function Validasi() {
return;
}
// ✅ Finalize registration
const finalizeRes = await fetch('/api/auth/finalize-registration', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -148,7 +149,7 @@ export default function Validasi() {
};
const handleLoginVerification = async () => {
const loginRes = await fetch('/api/verify-otp-login', {
const loginRes = await fetch('/api/auth/verify-otp-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor, otp, kodeId }),
@@ -206,7 +207,7 @@ export default function Validasi() {
// Clear cookie
try {
await fetch('/api/clear-flow', {
await fetch('/api/auth/clear-flow', {
method: 'POST',
credentials: 'include'
});
@@ -218,7 +219,7 @@ export default function Validasi() {
const handleResend = async () => {
if (!nomor) return;
try {
const res = await fetch('/api/resend', {
const res = await fetch('/api/auth/resend', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor }),

View File

@@ -361,6 +361,7 @@ function CreateAPBDes() {
data={[
{ value: 'pendapatan', label: 'Pendapatan' },
{ value: 'belanja', label: 'Belanja' },
{ value: 'pembiayaan', label: 'Pembiayaan' },
]}
value={newItem.level === 1 ? null : newItem.tipe}
onChange={(val) => setNewItem({ ...newItem, tipe: val as any })}

View File

@@ -93,6 +93,7 @@ function ListUser({ search }: { search: string }) {
const success = await stateUser.update.submit({
id: userId,
roleId: newRoleId,
});
if (success) {
@@ -136,9 +137,10 @@ function ListUser({ search }: { search: string }) {
}
};
const filteredData = (data || []).filter(
(item) => item.roleId !== "0" // asumsikan id role SUPERADMIN = "0"
);
const filteredData = (data || []).filter((item) => {
return item.roleId !== "0" && item.roleId !== "1";
});
if (loading || !data) {
return (
@@ -183,7 +185,7 @@ function ListUser({ search }: { search: string }) {
<Select
placeholder="Pilih role"
data={stateRole.findMany.data
.filter(r => r.id !== "0") // ❌ Sembunyikan SUPERADMIN
.filter(r => r.id !== "0" && r.id !== "1") // ❌ Sembunyikan SUPERADMIN dan DEVELOPER
.map(r => ({
label: r.name,
value: r.id,

View File

@@ -435,6 +435,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
const router = useRouter();
const segments = useSelectedLayoutSegments().map((s) => _.lowerCase(s));
useEffect(() => {
const fetchUser = async () => {

View File

@@ -23,7 +23,7 @@ export default async function userUpdate(context: Context) {
const currentUser = await prisma.user.findUnique({
where: { id },
select: { roleId: true, isActive: true }
select: { roleId: true, isActive: true },
});
if (!currentUser) {
@@ -31,7 +31,15 @@ export default async function userUpdate(context: Context) {
}
const isRoleChanged = roleId && currentUser.roleId !== roleId;
const isActiveChanged = isActive !== undefined && currentUser.isActive !== isActive;
const isActiveChanged =
isActive !== undefined && currentUser.isActive !== isActive;
// ✅ Jika role berubah, hapus semua akses menu yang ada
if (isRoleChanged) {
await prisma.userMenuAccess.deleteMany({
where: { userId: id }
});
}
// Update user
const updatedUser = await prisma.user.update({
@@ -48,10 +56,11 @@ export default async function userUpdate(context: Context) {
nomor: true,
isActive: true,
roleId: true,
role: { select: { name: true } }
}
role: { select: { name: true } },
},
});
// ✅ HAPUS SEMUA SESI USER DI DATABASE
if (isRoleChanged) {
await prisma.userSession.deleteMany({ where: { userId: id } });
@@ -62,11 +71,13 @@ export default async function userUpdate(context: Context) {
roleChanged: isRoleChanged,
isActiveChanged,
data: updatedUser,
message: isRoleChanged
message: isRoleChanged
? `Role ${updatedUser.username} diubah. User akan logout otomatis.`
: isActiveChanged
? `${updatedUser.username} ${isActive ? 'diaktifkan' : 'dinonaktifkan'}.`
: "User berhasil diupdate"
? `${updatedUser.username} ${
isActive ? "diaktifkan" : "dinonaktifkan"
}.`
: "User berhasil diupdate",
};
} catch (e: any) {
console.error("❌ Error update user:", e);
@@ -75,4 +86,4 @@ export default async function userUpdate(context: Context) {
message: "Gagal mengupdate user: " + (e.message || "Unknown error"),
};
}
}
}

View File

@@ -1,144 +0,0 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
// app/api/_lib/api_fetch_auth.ts
// app/api/_lib/api_fetch_auth.ts
export const apiFetchLogin = async ({ nomor }: { nomor: string }) => {
if (!nomor || nomor.replace(/\D/g, '').length < 10) {
throw new Error('Nomor tidak valid');
}
const cleanPhone = nomor.replace(/\D/g, '');
const response = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nomor: cleanPhone }),
credentials: 'include'
});
// Pastikan respons bisa di-parse sebagai JSON
let data;
try {
data = await response.json();
} catch (e) {
console.error("Non-JSON response from /api/login:", await response.text());
throw new Error('Respons server tidak valid');
}
if (!response.ok) {
throw new Error(data.message || 'Gagal memproses login');
}
// Validasi minimal respons
if (typeof data.success !== 'boolean' || typeof data.isRegistered !== 'boolean') {
throw new Error('Respons tidak sesuai format');
}
if (data.success) {
if (data.isRegistered && !data.kodeId) {
throw new Error('Kode verifikasi tidak ditemukan untuk user terdaftar');
}
return data; // { success, isRegistered, kodeId? }
} else {
throw new Error(data.message || 'Login gagal');
}
};
export const apiFetchRegister = async ({
username,
nomor,
}: {
username: string;
nomor: string;
}) => {
const cleanPhone = nomor.replace(/\D/g, '');
if (cleanPhone.length < 10) throw new Error('Nomor tidak valid');
const response = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: username.trim(), nomor: cleanPhone }),
credentials: 'include',
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Gagal mengirim OTP');
return data;
};
export const apiFetchOtpData = async ({ kodeId }: { kodeId: string }) => {
if (!kodeId) {
throw new Error('Kode ID tidak valid');
}
const response = await fetch("/api/otp-data", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kodeId }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Gagal memuat data OTP');
}
return data;
};
// Ganti endpoint ke verify-otp-login
export const apiFetchVerifyOtp = async ({ nomor, otp, kodeId }: { nomor: string; otp: string; kodeId: string }) => {
const response = await fetch('/api/verify-otp-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor, otp, kodeId }),
});
const data = await response.json();
return {
success: response.ok,
...data,
status: response.status,
};
};
// Di dalam api_fetch_auth.ts
export async function apiFetchUserMenuAccess(userId: string): Promise<{
success: boolean;
menuIds?: string[];
message?: string;
}> {
try {
const res = await fetch(`/api/admin/user-menu-access/${userId}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
const data = await res.json();
return data;
} catch (error) {
console.error('API Fetch User Menu Access Error:', error);
return { success: false, message: 'Gagal memuat menu akses' };
}
}
export async function apiUpdateUserMenuAccess(
userId: string,
menuIds: string[]
): Promise<{ success: boolean; message?: string }> {
try {
const res = await fetch('/api/admin/user-menu-access', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, menuIds }),
});
const data = await res.json();
return data;
} catch (error) {
console.error('API Update User Menu Access Error:', error);
return { success: false, message: 'Gagal menyimpan menu akses' };
}
}

View File

@@ -1,149 +0,0 @@
// src/app/api/auth/finalize-registration/route.ts
import prisma from "@/lib/prisma";
import { NextResponse } from "next/server";
import { sessionCreate } from "../_lib/session_create";
// ✅ Gunakan STRING untuk roleId
const DEFAULT_MENUS_BY_ROLE: Record<string, string[]> = {
"0": [
"Landing Page", "PPID", "Desa", "Kesehatan", "Keamanan",
"Ekonomi", "Inovasi", "Lingkungan", "Pendidikan", "User & Role"
],
"1": [
"Landing Page", "PPID", "Desa", "Keamanan",
"Ekonomi", "Inovasi", "Lingkungan", "User & Role"
],
"2": ["Landing Page", "Desa", "Ekonomi", "Inovasi", "Lingkungan"],
"3": ["Kesehatan"],
"4": ["Pendidikan"],
};
export async function POST(req: Request) {
try {
const { nomor, username, kodeId } = await req.json();
const cleanNomor = nomor.replace(/\D/g, "");
if (!cleanNomor || !username || !kodeId) {
return NextResponse.json(
{ success: false, message: "Data tidak lengkap" },
{ status: 400 }
);
}
// Verify OTP
const otpRecord = await prisma.kodeOtp.findUnique({
where: { id: kodeId },
select: { nomor: true, isActive: true }
});
if (!otpRecord?.isActive || otpRecord.nomor !== cleanNomor) {
return NextResponse.json(
{ success: false, message: "OTP tidak valid" },
{ status: 400 }
);
}
// Check duplicate username
if (await prisma.user.findFirst({ where: { username } })) {
return NextResponse.json(
{ success: false, message: "Username sudah digunakan" },
{ status: 409 }
);
}
// Check duplicate nomor
if (await prisma.user.findUnique({ where: { nomor: cleanNomor } })) {
return NextResponse.json(
{ success: false, message: "Nomor sudah terdaftar" },
{ status: 409 }
);
}
// 🔥 Tentukan roleId sebagai STRING
const targetRoleId = "2"; // ✅ Default ADMIN_DESA (roleId "2")
// Validasi role exists
const roleExists = await prisma.role.findUnique({
where: { id: targetRoleId },
select: { id: true }
});
if (!roleExists) {
return NextResponse.json(
{ success: false, message: "Role tidak valid" },
{ status: 400 }
);
}
// ✅ Create user (inactive, waiting approval)
const newUser = await prisma.user.create({
data: {
username,
nomor: cleanNomor,
roleId: targetRoleId,
isActive: false, // Waiting for admin approval
},
});
// ✅ Berikan akses menu default based on role
const menuIds = DEFAULT_MENUS_BY_ROLE[targetRoleId] || [];
if (menuIds.length > 0) {
await prisma.userMenuAccess.createMany({
data: menuIds.map(menuId => ({
userId: newUser.id,
menuId,
})),
skipDuplicates: true, // ✅ Avoid duplicate errors
});
}
// ✅ Mark OTP as used
await prisma.kodeOtp.update({
where: { id: kodeId },
data: { isActive: false },
});
// ✅ Create session token
const token = await sessionCreate({
sessionKey: process.env.BASE_SESSION_KEY!,
jwtSecret: process.env.BASE_TOKEN_KEY!,
exp: "30 day",
user: {
id: newUser.id,
nomor: newUser.nomor,
username: newUser.username,
roleId: newUser.roleId,
isActive: false, // User belum aktif
},
invalidatePrevious: false,
});
// ✅ PENTING: Return JSON response (bukan redirect)
const response = NextResponse.json({
success: true,
message: "Registrasi berhasil. Menunggu persetujuan admin.",
userId: newUser.id,
});
// ✅ Set session cookie
const cookieName = process.env.BASE_SESSION_KEY || 'session';
response.cookies.set(cookieName, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: 'lax',
path: "/",
maxAge: 30 * 24 * 60 * 60, // 30 days
});
return response;
} catch (error) {
console.error("❌ Finalize Registration Error:", error);
return NextResponse.json(
{ success: false, message: "Registrasi gagal. Silakan coba lagi." },
{ status: 500 }
);
} finally {
await prisma.$disconnect();
}
}

View File

@@ -1,59 +0,0 @@
// src/app/api/auth/me/route.ts
import { NextResponse } from 'next/server';
import { verifySession } from '../_lib/session_verify';
import prisma from '@/lib/prisma';
export async function GET() {
try {
const sessionUser = await verifySession();
if (!sessionUser) {
return NextResponse.json(
{ success: false, message: "Unauthorized", user: null },
{ status: 401 }
);
}
const [dbUser, menuAccess] = await Promise.all([
prisma.user.findUnique({
where: { id: sessionUser.id },
select: {
id: true,
username: true,
nomor: true,
roleId: true, // STRING!
isActive: true, // BOOLEAN!
},
}),
prisma.userMenuAccess.findMany({
where: { userId: sessionUser.id },
select: { menuId: true },
}),
]);
if (!dbUser) {
return NextResponse.json(
{ success: false, message: "User not found", user: null },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
user: {
id: dbUser.id,
name: dbUser.username,
username: dbUser.username,
nomor: dbUser.nomor,
roleId: dbUser.roleId, // STRING!
isActive: dbUser.isActive, // BOOLEAN!
menuIds: menuAccess.map(m => m.menuId),
},
});
} catch (error) {
console.error("❌ Error in /api/me:", error);
return NextResponse.json(
{ success: false, message: "Internal server error", user: null },
{ status: 500 }
);
}
}

View File

@@ -1,8 +1,7 @@
// src/app/api/auth/finalize-registration/route.ts
import prisma from "@/lib/prisma";
import { NextResponse } from "next/server";
import { sessionCreate } from "../../[auth]/_lib/session_create";
import { sessionCreate } from "../_lib/session_create";
// ✅ Gunakan STRING untuk roleId
const DEFAULT_MENUS_BY_ROLE: Record<string, string[]> = {

View File

@@ -12,6 +12,7 @@ export async function GET() {
{ status: 401 }
);
}
const [dbUser, menuAccess] = await Promise.all([
prisma.user.findUnique({

View File

@@ -151,7 +151,7 @@ function Page() {
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() => router.push(`/darmasaba/ppid/daftar-informasi-publik-desa-darmasaba/${item.id}`)}
onClick={() => router.push(`/darmasaba/ppid/daftar-informasi-publik/${item.id}`)}
>
Detail
</Button>

View File

@@ -1,13 +1,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// src/app/admin/(dashboard)/landing-page/APBDes/APBDesProgress.tsx
'use client';
import { Box, Paper, Progress, Stack, Text, Title } from '@mantine/core';
import { useProxy } from 'valtio/utils';
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes';
import colors from '@/con/colors';
import { Box, Paper, Progress, Stack, Text, Title } from '@mantine/core';
import { APBDesData } from './types';
function formatRupiah(value: number) {
return new Intl.NumberFormat('id-ID', {
@@ -17,31 +12,33 @@ function formatRupiah(value: number) {
}).format(value);
}
function APBDesProgress() {
const state = useProxy(apbdes);
const data = state.findMany.data || [];
interface APBDesProgressProps {
apbdesData: APBDesData;
}
// Ambil APBDes pertama (misalnya, jika hanya satu tahun ditampilkan)
const apbdesItem = data[0]; // 👈 sesuaikan logika jika ada banyak APBDes
if (!apbdesItem) {
return (
<Box py="md" px={{ base: 'md', md: 100 }}>
<Text c="dimmed">Belum ada data APBDes untuk ditampilkan.</Text>
</Box>
);
function APBDesProgress({ apbdesData }: APBDesProgressProps) {
// Return null if apbdesData is not available yet
if (!apbdesData) {
return null;
}
const items = apbdesItem.items || [];
const items = apbdesData.items || [];
const sortedItems = [...items].sort((a, b) => a.kode.localeCompare(b.kode));
// Kelompokkan berdasarkan tipe
const pendapatanItems = sortedItems.filter(item => item.tipe === 'pendapatan');
const belanjaItems = sortedItems.filter(item => item.tipe === 'belanja');
const pembiayaanItems = sortedItems.filter(item => item.tipe === 'pembiayaan'); // jika ada
const pembiayaanItems = sortedItems.filter(item => item.tipe === 'pembiayaan');
// Items without a type (should be filtered out from calculations)
const untypedItems = sortedItems.filter(item => !item.tipe);
if (untypedItems.length > 0) {
console.warn(`Found ${untypedItems.length} items without a type. These will be excluded from calculations.`);
}
// Hitung total per kategori
const calcTotal = (items: any[]) => {
const calcTotal = (items: { anggaran: number; realisasi: number }[]) => {
const anggaran = items.reduce((sum, item) => sum + item.anggaran, 0);
const realisasi = items.reduce((sum, item) => sum + item.realisasi, 0);
const persen = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
@@ -50,10 +47,10 @@ function APBDesProgress() {
const pendapatan = calcTotal(pendapatanItems);
const belanja = calcTotal(belanjaItems);
const pembiayaan = calcTotal(pembiayaanItems); // bisa kosong
const pembiayaan = calcTotal(pembiayaanItems);
// Render satu progress bar
const renderProgress = (label: string, dataset: any) => {
const renderProgress = (label: string, dataset: { realisasi: number; anggaran: number; persen: number }) => {
const isPembiayaan = label.includes('Pembiayaan');
return (
@@ -71,8 +68,8 @@ function APBDesProgress() {
root: { backgroundColor: '#d7e3f1' },
section: {
backgroundColor: isPembiayaan
? 'green' // warna hijau untuk pembiayaan
: colors['blue-button'], // biru untuk pendapatan/belanja
? 'green'
: colors['blue-button'],
position: 'relative',
'&::after': {
content: `'${dataset.persen.toFixed(2)}%'`,
@@ -102,7 +99,7 @@ function APBDesProgress() {
>
<Stack gap="lg">
<Title order={4} c={colors['blue-button']} ta="center">
Grafik Pelaksanaan APBDes Tahun {apbdesItem.tahun}
Grafik Pelaksanaan APBDes Tahun {apbdesData.tahun}
</Title>
<Text ta="center" fw="bold" fz="sm" c="dimmed">
@@ -112,97 +109,9 @@ function APBDesProgress() {
{renderProgress('Pendapatan Desa', pendapatan)}
{renderProgress('Belanja Desa', belanja)}
{renderProgress('Pembiayaan Desa', pembiayaan)}
{pembiayaanItems.length > 0 && renderProgress('Pembiayaan Desa', pembiayaan)}
</Stack>
</Paper>
);
}
export default APBDesProgress;
// /* eslint-disable @typescript-eslint/no-explicit-any */
// 'use client';
// import { Box, Paper, Stack, Text, Title } from '@mantine/core';
// import { BarChart } from '@mantine/charts';
// import { useProxy } from 'valtio/utils';
// import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes';
// import colors from '@/con/colors';
// function APBDesProgress() {
// const state = useProxy(apbdes);
// const data = state.findMany.data || [];
// const apbdesItem = data[0];
// if (!apbdesItem) {
// return (
// <Box py="md" px={{ base: 'md', md: 100 }}>
// <Text c="dimmed">Belum ada data APBDes untuk ditampilkan.</Text>
// </Box>
// );
// }
// const items = apbdesItem.items || [];
// const sortedItems = [...items].sort((a, b) => a.kode.localeCompare(b.kode));
// const pendapatanItems = sortedItems.filter(i => i.tipe === 'pendapatan');
// const belanjaItems = sortedItems.filter(i => i.tipe === 'belanja');
// const pembiayaanItems = sortedItems.filter(i => i.tipe === 'pembiayaan');
// const total = (rows: any[]) => {
// const anggaran = rows.reduce((s, i) => s + i.anggaran, 0);
// const realisasi = rows.reduce((s, i) => s + i.realisasi, 0);
// return anggaran === 0 ? 0 : (realisasi / anggaran) * 100;
// };
// const chartData = [
// { name: 'Pendapatan', persen: total(pendapatanItems) },
// { name: 'Belanja', persen: total(belanjaItems) },
// ];
// if (pembiayaanItems.length > 0) {
// chartData.push({ name: 'Pembiayaan', persen: total(pembiayaanItems) });
// }
// return (
// <Paper
// mx={{ base: 'md', md: 100 }}
// p="xl"
// radius="md"
// shadow="sm"
// withBorder
// bg={colors['white-1']}
// >
// <Stack gap="lg">
// <Title order={4} c={colors['blue-button']} ta="center">
// Grafik Pelaksanaan APBDes Tahun {apbdesItem.tahun}
// </Title>
// <Text ta="center" fw="bold" fz="sm" c="dimmed">
// Persentase Realisasi (%) dari Anggaran
// </Text>
// <BarChart
// h={200}
// data={chartData}
// orientation="vertical"
// dataKey="name"
// barProps={{ radius: 6 }}
// series={[
// {
// name: 'persen',
// label: 'Persentase',
// color: colors['blue-button'],
// },
// ]}
// yAxisProps={{
// domain: [0, 100],
// }}
// valueFormatter={(v) => `${v.toFixed(1)}%`}
// />
// </Stack>
// </Paper>
// );
// }
// export default APBDesProgress;
export default APBDesProgress;

View File

@@ -1,30 +1,8 @@
// src/app/admin/(dashboard)/landing-page/APBDes/APBDesTable.tsx
'use client';
import { Box, Paper, Table, Text, Title, Badge, Group } from '@mantine/core';
import { useProxy } from 'valtio/utils';
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes';
import colors from '@/con/colors';
interface APBDesItem {
id: string;
kode: string;
uraian: string;
anggaran: number;
realisasi: number;
selisih: number;
persentase: number;
level: number;
tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
}
interface APBDesData {
id: string;
tahun: number;
items: APBDesItem[];
image?: { id: string; url: string } | null;
file?: { id: string; url: string } | null;
}
import { APBDesData } from './types';
// Helper: Format Rupiah, tapi jika 0 → tampilkan '-'
function formatRupiahOrEmpty(value: number): string {
@@ -51,22 +29,12 @@ function getIndent(level: number) {
};
}
function APBDesTable() {
const state = useProxy(apbdes);
const data = state.findMany.data || [];
interface APBDesTableProps {
apbdesData: APBDesData;
}
// Get the first APBDes item
const apbdesItem = data[0] as unknown as APBDesData | undefined;
if (!apbdesItem) {
return (
<Box py="md" px={{ base: 'md', md: 100 }}>
<Text c="dimmed">Belum ada data APBDes untuk ditampilkan.</Text>
</Box>
);
}
const items = Array.isArray(apbdesItem.items) ? apbdesItem.items : [];
function APBDesTable({ apbdesData }: APBDesTableProps) {
const items = Array.isArray(apbdesData.items) ? apbdesData.items : [];
const sortedItems = [...items].sort((a, b) => a.kode.localeCompare(b.kode));
// Calculate totals
@@ -76,13 +44,13 @@ function APBDesTable() {
const totalPersentase = totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0;
return (
<Box py="md" px={{ base: 'md', md: 100 }}>
<Box pt={"xs"} pb="md" px={{ base: 'md', md: 100 }}>
<Title order={4} c={colors['blue-button']} mb="sm">
Rincian APBDes Tahun {apbdesItem.tahun}
Rincian APBDes Tahun {apbdesData.tahun}
</Title>
<Paper withBorder radius="md" shadow="xs" p="md">
<Box style={{overflowY: 'auto' }}>
<Box style={{ overflowY: 'auto' }}>
<Table withColumnBorders highlightOnHover>
<Table.Thead bg="#2c5f78">
<Table.Tr>
@@ -109,9 +77,7 @@ function APBDesTable() {
<Table.Td style={getIndent(item.level)}>
<Group gap="xs" align="flex-start">
<Text fw={item.level === 1 ? 'bold' : 'normal'}>{item.kode}</Text>
<Text fz="sm" >
{item.uraian}
</Text>
<Text fz="sm">{item.uraian}</Text>
</Group>
</Table.Td>
<Table.Td ta="right">{formatRupiahOrEmpty(item.anggaran)}</Table.Td>

View File

@@ -0,0 +1,42 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export type APBDesTipe = 'pendapatan' | 'belanja' | 'pembiayaan';
export function isAPBDesTipe(tipe: string | null | undefined): tipe is APBDesTipe {
return tipe === 'pendapatan' || tipe === 'belanja' || tipe === 'pembiayaan';
}
export interface APBDesItem {
id: string;
kode: string;
uraian: string;
anggaran: number;
realisasi: number;
selisih: number;
persentase: number;
level: number;
tipe?: APBDesTipe | null;
// Additional fields from API
createdAt?: Date;
updatedAt?: Date;
deletedAt?: Date | null;
isActive?: boolean;
apbdesId?: string;
}
export interface APBDesData {
id: string;
tahun: number | null;
items: APBDesItem[];
image?: { id: string; url: string } | null;
file?: { id: string; url: string } | null;
}
export function transformAPBDesData(data: any): APBDesData {
return {
...data,
items: data.items.map((item: any) => ({
...item,
tipe: isAPBDesTipe(item.tipe) ? item.tipe : null
}))
};
}

View File

@@ -4,19 +4,21 @@
import PendapatanAsliDesa from '@/app/admin/(dashboard)/_state/ekonomi/PADesa'
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes'
import colors from '@/con/colors'
import { ActionIcon, BackgroundImage, Box, Center, Container, Group, Loader, SimpleGrid, Stack, Text, Title } from '@mantine/core'
import { ActionIcon, BackgroundImage, Box, Center, Container, Group, Loader, Select, SimpleGrid, Stack, Text, Title } from '@mantine/core'
import { IconDownload } from '@tabler/icons-react'
import { Link } from 'next-view-transitions'
import { useEffect, useState } from 'react'
import { useProxy } from 'valtio/utils'
import BackButton from '../../(pages)/desa/layanan/_com/BackButto'
import APBDesProgress from './lib/apbDesaProgress'
import APBDesTable from './lib/apbDesaTable'
import APBDesProgress from './lib/apbDesaProgress'
import { transformAPBDesData } from './lib/types'
function Page() {
const state = useProxy(apbdes)
const paDesaState = useProxy(PendapatanAsliDesa.ApbDesa)
const [loading, setLoading] = useState(false)
const [selectedYear, setSelectedYear] = useState<string | null>(null)
useEffect(() => {
const loadData = async () => {
try {
@@ -34,6 +36,23 @@ function Page() {
const dataAPBDes = state.findMany.data || []
// Buat daftar tahun unik dari data
const years = Array.from(new Set(dataAPBDes.map((item: any) => item.tahun)))
.sort((a, b) => b - a) // urutkan descending
.map(year => ({ value: year.toString(), label: `Tahun ${year}` }))
// Pilih tahun pertama sebagai default jika belum ada yang dipilih
useEffect(() => {
if (years.length > 0 && !selectedYear) {
setSelectedYear(years[0].value)
}
}, [years, selectedYear])
// Transform and filter data based on selected year
const currentApbdes = dataAPBDes.length > 0
? transformAPBDesData(dataAPBDes.find(item => item?.tahun?.toString() === selectedYear) || dataAPBDes[0])
: null
return (
<Stack pos="relative" bg={colors.Bg} py="xl" gap={32}>
<Box px={{ base: 'md', md: 100 }}>
@@ -94,8 +113,31 @@ function Page() {
))}
</SimpleGrid>
)}
<APBDesTable />
<APBDesProgress />
{/* 🔥 COMBOBOX UNTUK PILIH TAHUN */}
<Box px={{ base: 'md', md: 100 }}>
<Select
label="Pilih Tahun APBDes"
placeholder="Pilih tahun"
value={selectedYear}
onChange={setSelectedYear}
data={years}
w={{ base: '100%', sm: 200 }}
searchable
clearable
nothingFoundMessage="Tidak ada tahun tersedia"
/>
</Box>
{/* ❗ Pass currentApbdes ke komponen anak */}
{currentApbdes ? (
<>
<APBDesTable apbdesData={currentApbdes} />
<APBDesProgress apbdesData={currentApbdes} />
</>
) : (
<Box px={{ base: 'md', md: 100 }} py="md">
<Text c="dimmed">Tidak ada data APBDes untuk tahun yang dipilih.</Text>
</Box>
)}
</Stack>
)
}

View File

@@ -1,9 +1,11 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes'
import APBDesProgress from '@/app/darmasaba/(tambahan)/apbdes/lib/apbDesaProgress'
import { transformAPBDesData } from '@/app/darmasaba/(tambahan)/apbdes/lib/types'
import colors from '@/con/colors'
import { ActionIcon, BackgroundImage, Box, Button, Center, Flex, Group, Loader, SimpleGrid, Stack, Text } from '@mantine/core'
import { ActionIcon, BackgroundImage, Box, Button, Center, Flex, Group, Loader, Select, SimpleGrid, Stack, Text } from '@mantine/core'
import { IconDownload } from '@tabler/icons-react'
import Link from 'next/link'
import { useEffect, useState } from 'react'
@@ -12,6 +14,7 @@ import { useProxy } from 'valtio/utils'
function Apbdes() {
const state = useProxy(apbdes)
const [loading, setLoading] = useState(false)
const [selectedYear, setSelectedYear] = useState<string | null>(null)
const textHeading = {
title: 'APBDes',
@@ -32,6 +35,24 @@ function Apbdes() {
loadData()
}, [])
const dataAPBDes = state.findMany.data || []
const years = Array.from(new Set(dataAPBDes.map((item: any) => item.tahun)))
.sort((a, b) => b - a) // urutkan descending
.map(year => ({ value: year.toString(), label: `Tahun ${year}` }))
// Pilih tahun pertama sebagai default jika belum ada yang dipilih
useEffect(() => {
if (years.length > 0 && !selectedYear) {
setSelectedYear(years[0].value)
}
}, [years, selectedYear])
// Transform and filter data based on selected year
const currentApbdes = dataAPBDes.length > 0
? transformAPBDesData(dataAPBDes.find(item => item?.tahun?.toString() === selectedYear) || dataAPBDes[0])
: null
const data = (state.findMany.data || []).slice(0, 3)
return (
@@ -60,8 +81,30 @@ function Apbdes() {
</Button>
</Group>
{/* Chart */}
<APBDesProgress />
{/* 🔥 COMBOBOX UNTUK PILIH TAHUN */}
<Box px={{ base: 'md', md: 100 }}>
<Select
label="Pilih Tahun APBDes"
placeholder="Pilih tahun"
value={selectedYear}
onChange={setSelectedYear}
data={years}
w={{ base: '100%', sm: 200 }}
searchable
clearable
nothingFoundMessage="Tidak ada tahun tersedia"
/>
</Box>
{currentApbdes ? (
<>
<APBDesProgress apbdesData={currentApbdes} />
</>
) : (
<Box px={{ base: 'md', md: 100 }} py="md">
<Text c="dimmed">Tidak ada data APBDes untuk tahun yang dipilih.</Text>
</Box>
)}
<SimpleGrid mx={{ base: 'md', md: 100 }} cols={{ base: 1, sm: 3 }} spacing="lg" pb={"xl"}>
{loading ? (
@@ -129,7 +172,7 @@ function Apbdes() {
)}
</SimpleGrid>
</Stack>
)
}

View File

@@ -0,0 +1,173 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Syarat & Ketentuan Penggunaan HIPMI Badung Connect</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #1e293b;
background-color: #f8fafc;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 40px 20px;
background-color: white;
min-height: 100vh;
}
h1 {
font-size: 2rem;
font-weight: 700;
color: #1e3a5f;
margin-bottom: 1.5rem;
line-height: 1.3;
}
h2 {
font-size: 1.5rem;
font-weight: 700;
color: #1e3a5f;
margin-top: 2.5rem;
margin-bottom: 1rem;
}
p {
margin-bottom: 1rem;
color: #334155;
}
strong {
font-weight: 600;
color: #1e293b;
}
ul {
margin-left: 1.5rem;
margin-bottom: 1.5rem;
}
li {
margin-bottom: 0.5rem;
color: #334155;
}
.intro {
margin-bottom: 2rem;
padding: 1.25rem;
background-color: #f1f5f9;
border-radius: 8px;
border-left: 4px solid #1e3a5f;
}
.footer {
margin-top: 3rem;
padding-top: 2rem;
border-top: 1px solid #e2e8f0;
text-align: center;
color: #64748b;
font-size: 0.875rem;
}
@media (max-width: 768px) {
.container {
padding: 24px 16px;
}
h1 {
font-size: 1.5rem;
}
h2 {
font-size: 1.25rem;
margin-top: 2rem;
}
}
@media (max-width: 480px) {
h1 {
font-size: 1.25rem;
}
h2 {
font-size: 1.125rem;
}
ul {
margin-left: 1.25rem;
}
}
</style>
</head>
<body>
<div class="container">
<h1>Syarat & Ketentuan Penggunaan HIPMI Badung Connect</h1>
<div class="intro">
<p>Dengan menggunakan aplikasi <strong>HIPMI Badung Connect</strong> ("Aplikasi"), Anda setuju untuk mematuhi dan terikat oleh syarat dan ketentuan berikut. Jika Anda tidak setuju dengan ketentuan ini, harap jangan gunakan Aplikasi.</p>
</div>
<h2>1. Definisi</h2>
<p><strong>HIPMI Badung Connect</strong> adalah platform digital resmi untuk anggota Himpunan Pengusaha Muda Indonesia (HIPMI) Kabupaten Badung, yang bertujuan memfasilitasi jaringan, kolaborasi, dan pertumbuhan bisnis para pengusaha muda.</p>
<h2>2. Larangan Konten Tidak Pantas</h2>
<p>Anda <strong>dilarang keras</strong> memposting, mengirim, membagikan, atau mengunggah konten apa pun yang mengandung:</p>
<ul>
<li>Ujaran kebencian, diskriminasi, atau konten SARA (Suku, Agama, Ras, Antar-golongan)</li>
<li>Pornografi, konten seksual eksplisit, atau gambar tidak senonoh</li>
<li>Ancaman, pelecehan, bullying, atau perilaku melecehkan</li>
<li>Informasi palsu, hoaks, spam, atau konten menyesatkan</li>
<li>Konten ilegal, melanggar hukum, atau melanggar hak kekayaan intelektual pihak lain</li>
<li>Promosi narkoba, perjudian, atau aktivitas ilegal lainnya</li>
</ul>
<h2>3. Tanggung Jawab Pengguna</h2>
<p>Anda bertanggung jawab penuh atas setiap konten yang Anda unggah atau bagikan melalui fitur-fitur berikut:</p>
<ul>
<li>Profil (bio, foto, portofolio)</li>
<li>Forum diskusi</li>
<li>Chat pribadi atau grup</li>
<li>Lowongan kerja, investasi, dan donasi</li>
</ul>
<p>Konten yang melanggar ketentuan ini dapat dihapus kapan saja tanpa pemberitahuan.</p>
<h2>4. Tindakan terhadap Pelanggaran</h2>
<p>Jika kami menerima laporan atau menemukan konten yang melanggar ketentuan ini, kami akan:</p>
<ul>
<li>Segera menghapus konten tersebut</li>
<li>Memberikan peringatan atau memblokir akun pengguna</li>
<li>Dalam kasus berat, melaporkan ke pihak berwajib sesuai hukum yang berlaku</li>
</ul>
<p>Tim kami berkomitmen untuk menanggapi laporan konten tidak pantas <strong>dalam waktu 24 jam</strong>.</p>
<h2>5. Mekanisme Pelaporan</h2>
<p>Anda dapat melaporkan konten atau pengguna yang mencurigakan melalui:</p>
<ul>
<li>Tombol <strong>"Laporkan"</strong> di setiap posting forum atau pesan chat</li>
<li>Tombol <strong>"Blokir Pengguna"</strong> di profil pengguna</li>
</ul>
<p>Setiap laporan akan ditangani secara rahasia dan segera.</p>
<h2>6. Perubahan Ketentuan</h2>
<p>Kami berhak memperbarui Syarat & Ketentuan ini sewaktu-waktu. Versi terbaru akan dipublikasikan di halaman ini dengan tanggal revisi yang diperbarui.</p>
<h2>7. Kontak</h2>
<p>Jika Anda memiliki pertanyaan tentang ketentuan ini, silakan hubungi kami di:<br>
<strong>bip.baliinteraktifperkasa@gmail.com</strong></p>
<div class="footer">
© 2025 Bali Interaktif Perkasa. All rights reserved.
</div>
</div>
</body>
</html>

View File

@@ -1,31 +1,105 @@
/* styles/globals.css */
/* ===================================
1. IMPORT CSS LIBRARIES
=================================== */
@import "@mantine/carousel/styles.css";
@import "@mantine/dropzone/styles.css";
@import "@mantine/charts/styles.css";
@import "@mantine/dates/styles.css";
@import "@mantine/tiptap/styles.css";
@import "animate.css";
@import "react-simple-toasts/dist/style.css";
@import "react-simple-toasts/dist/theme/dark.css";
@import "primereact/resources/themes/lara-light-blue/theme.css";
@import "primereact/resources/primereact.min.css";
@import "primeicons/primeicons.css";
/* ===================================
2. FONT FACE - OPTIMIZED
=================================== */
@font-face {
font-family: 'San Francisco';
src: url('/assets/fonts/font.otf') format('opentype');
font-weight: normal;
font-style: normal;
font-display: swap; /* ✅ TAMBAHKAN INI - Penting untuk PageSpeed! */
}
/* ===================================
3. RESET & BASE STYLES
=================================== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%; /* Prevent font scaling in landscape */
-moz-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
margin: 0;
font-family: 'San Francisco', -apple-system, BlinkMacSystemFont, 'Segoe UI',
Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ===================================
4. GLASS EFFECTS - OPTIMIZED
=================================== */
.glass {
background: rgba(255, 255, 255, 0.2);
-webkit-backdrop-filter: blur(40px);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
position: fixed;
z-index: 50;
width: 100%;
height: 100vh;
will-change: transform; /* ✅ Hardware acceleration */
}
.glass2 {
background: rgba(255, 255, 255, 0.3);
-webkit-backdrop-filter: blur(40px);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
position: fixed;
z-index: 1;
will-change: transform; /* ✅ Hardware acceleration */
}
.glass3 {
background: rgba(255, 255, 255, 0.3);
-webkit-backdrop-filter: blur(40px);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
will-change: transform; /* ✅ Hardware acceleration */
}
/* ===================================
5. PERFORMANCE OPTIMIZATION
=================================== */
img,
picture,
video,
canvas,
svg {
display: block;
max-width: 100%;
height: auto;
}
/* Reduce motion for accessibility */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@@ -1,20 +1,5 @@
// Import styles of packages that you've installed.
// All packages except `@mantine/hooks` require styles imports
import "@mantine/carousel/styles.css";
import "@mantine/core/styles.css";
import '@mantine/dropzone/styles.css';
import "animate.css";
import 'react-simple-toasts/dist/style.css';
import 'react-simple-toasts/dist/theme/dark.css';
import "./globals.css";
import '@mantine/charts/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/tiptap/styles.css';
import "primereact/resources/themes/lara-light-blue/theme.css";
import "primereact/resources/primereact.min.css";
import "primeicons/primeicons.css";
import LoadDataFirstClient from "@/app/darmasaba/_com/LoadDataFirstClient";
import {
@@ -23,19 +8,83 @@ import {
createTheme,
mantineHtmlProps,
} from "@mantine/core";
import { Metadata, Viewport } from "next";
import { ViewTransitions } from "next-view-transitions";
import { ToastContainer } from "react-toastify";
export const metadata = {
title: "Desa Darmasaba",
description: "Desa Darmasaba Kabupaten Badung",
// ✅ Pisahkan viewport ke export tersendiri
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 5,
};
export const metadata: Metadata = {
// ✅ Tambahkan metadataBase
metadataBase: new URL("https://cld-dkr-staging-desa-darmasaba.wibudev.com"),
title: {
default: "Desa Darmasaba",
template: "%s | Desa Darmasaba",
},
description: "Website resmi Desa Darmasaba, Kabupaten Badung, Bali. Informasi layanan publik, berita, dan profil desa.",
// ❌ HAPUS viewport dari sini
keywords: [
"desa darmasaba",
"darmasaba",
"badung",
"bali",
"desa",
"pemerintah desa",
"layanan publik",
"abang batan desa",
],
authors: [{ name: "Pemerintah Desa Darmasaba" }],
creator: "Desa Darmasaba",
publisher: "Desa Darmasaba",
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
icons: {
icon: "/assets/images/darmasaba-icon.png",
apple: "/assets/images/darmasaba-icon.png",
},
manifest: "/manifest.json",
openGraph: {
type: "website",
locale: "id_ID",
url: "https://cld-dkr-staging-desa-darmasaba.wibudev.com",
siteName: "Desa Darmasaba",
title: "Desa Darmasaba - Kabupaten Badung, Bali",
description: "Website resmi Desa Darmasaba, Kabupaten Badung, Bali. Informasi layanan publik, berita, dan profil desa.",
images: [
{
url: "/assets/images/darmasaba-icon.png",
width: 1200,
height: 630,
alt: "Desa Darmasaba",
},
],
},
category: "government",
other: {
"msapplication-TileColor": "#ffffff",
"theme-color": "#ffffff",
},
};
const theme = createTheme({
fontFamily:
"San Francisco, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, sans-serif",
fontFamilyMonospace:
"SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace",
fontFamily: "San Francisco, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif",
fontFamilyMonospace: "SFMono-Regular, Menlo, Monaco, Consolas, monospace",
headings: { fontFamily: "San Francisco, sans-serif" },
});
@@ -46,26 +95,23 @@ export default function RootLayout({
}) {
return (
<ViewTransitions>
<html lang="en" {...mantineHtmlProps}>
<html lang="id" {...mantineHtmlProps}>
<head>
<meta charSet="utf-8" />
<ColorSchemeScript />
<link
rel="icon"
href="/assets/images/darmasaba-icon.png"
sizes="any"
/>
</head>
<body>
<MantineProvider theme={theme}>
{children}
<LoadDataFirstClient />
<ToastContainer
position="bottom-center"
hideProgressBar
style={{ zIndex: 9999 }}
/>
</MantineProvider>
<ToastContainer position="bottom-center" hideProgressBar style={{
zIndex: 9999
}} />
</body>
<LoadDataFirstClient />
</html>
</ViewTransitions>
);
}
}

View File

@@ -0,0 +1,102 @@
import { Box, Container, Divider, List, ListItem, Paper, Stack, Text, Title } from '@mantine/core';
import React from 'react';
function Page() {
return (
<Container size="md" py={40}>
<Stack gap="xl">
<Title order={1} size="h1" fw={700} c="blue.9">
Syarat & Ketentuan Penggunaan Admin Desa Darmasaba
</Title>
<Paper p="lg" radius="md" withBorder bg="gray.0" style={{ borderLeft: '4px solid #1e3a5f' }}>
<Text c="gray.8">
Dengan menggunakan website <Text component="span" fw={600}>Admin Desa Darmasaba</Text> (&quot;Website&quot;),
Anda setuju untuk mematuhi dan terikat oleh syarat dan ketentuan berikut. Jika Anda tidak setuju
dengan ketentuan ini, harap jangan gunakan Website.
</Text>
</Paper>
<Box>
<Title order={2} size="h2" fw={700} c="blue.9" mb="md">
1. Definisi
</Title>
<Text c="gray.7">
<Text component="span" fw={600}>Admin Desa Darmasaba</Text> adalah website resmi untuk Admin Desa Darmasaba, yang bertujuan
menambahkan, menghapus, dan mengedit konten desa ke dalam website.
</Text>
</Box>
<Box>
<Title order={2} size="h2" fw={700} c="blue.9" mb="md">
2. Larangan Konten Tidak Pantas
</Title>
<Text c="gray.7" mb="md">
Anda <Text component="span" fw={600}>dilarang keras</Text> menambahkan, menghapus, dan mengedit konten desa apa pun yang mengandung:
</Text>
<List spacing="xs" c="gray.7">
<ListItem>Ujaran kebencian, diskriminasi, atau konten SARA (Suku, Agama, Ras, Antar-golongan)</ListItem>
<ListItem>Pornografi, konten seksual eksplisit, atau gambar tidak senonoh</ListItem>
<ListItem>Ancaman, pelecehan, bullying, atau perilaku melecehkan</ListItem>
<ListItem>Informasi palsu, hoaks, spam, atau konten menyesatkan</ListItem>
<ListItem>Konten ilegal, melanggar hukum, atau melanggar hak kekayaan intelektual pihak lain</ListItem>
<ListItem>Promosi narkoba, perjudian, atau aktivitas ilegal lainnya</ListItem>
</List>
</Box>
<Box>
<Title order={2} size="h2" fw={700} c="blue.9" mb="md">
3. Tanggung Jawab Pengguna
</Title>
<List spacing="xs" c="gray.7">
<ListItem>Anda bertanggung jawab penuh atas setiap konten yang Anda unggah atau bagikan.</ListItem>
<ListItem>Konten yang melanggar ketentuan ini dapat dihapus kapan saja tanpa pemberitahuan.</ListItem>
</List>
</Box>
<Box>
<Title order={2} size="h2" fw={700} c="blue.9" mb="md">
4. Tindakan terhadap Pelanggaran
</Title>
<Text c="gray.7" mb="md">
Jika kami menerima laporan atau menemukan konten yang melanggar ketentuan ini, kami akan:
</Text>
<List spacing="xs" c="gray.7">
<ListItem>Segera menghapus konten tersebut</ListItem>
<ListItem>Menghapus akun pengguna</ListItem>
<ListItem>Dalam kasus berat, melaporkan ke pihak berwajib sesuai hukum yang berlaku</ListItem>
</List>
</Box>
<Box>
<Title order={2} size="h2" fw={700} c="blue.9" mb="md">
5. Perubahan Ketentuan
</Title>
<Text c="gray.7">
Kami berhak memperbarui Syarat & Ketentuan ini sewaktu-waktu. Versi terbaru akan dipublikasikan di
halaman ini dengan tanggal revisi yang diperbarui.
</Text>
</Box>
<Box>
<Title order={2} size="h2" fw={700} c="blue.9" mb="md">
6. Kontak
</Title>
<Text c="gray.7">
Jika Anda memiliki pertanyaan tentang ketentuan ini, silakan hubungi kami di:
</Text>
<Text c="gray.7" fw={600} mt="xs">
bip.baliinteraktifperkasa@gmail.com
</Text>
</Box>
<Divider my="xl" />
<Text ta="center" c="gray.6" size="sm">
© 2025 Bali Interaktif Perkasa. All rights reserved.
</Text>
</Stack>
</Container>
);
}
export default Page;

View File

@@ -80,7 +80,7 @@ export default function WaitingRoom() {
// Force a session refresh
try {
const res = await fetch('/api/refresh-session', {
const res = await fetch('/api/auth/refresh-session', {
method: 'POST',
credentials: 'include'
});