Compare commits
83 Commits
fix-wa-otp
...
f6f0e10935
| Author | SHA1 | Date | |
|---|---|---|---|
| f6f0e10935 | |||
| 2108f403aa | |||
| c6c3eebadf | |||
|
|
0dabc204bc | ||
|
|
e8f8b51686 | ||
|
|
a4db3a149d | ||
|
|
fece983ac5 | ||
| 8b7eef5fee | |||
| 8b22d01e0d | |||
| dc13e37a02 | |||
| 2d2cbef29b | |||
| 8c8a96b830 | |||
| dc3eccacbf | |||
| ffe94992e5 | |||
| 4fb522f88f | |||
| 85332a8225 | |||
| 3fe2a5ccab | |||
| 363bfa65fb | |||
| dccf590cbf | |||
| f076b81d14 | |||
| b5ea3216e0 | |||
| 64b116588b | |||
| 63161e1a39 | |||
| 8b8c65dd1e | |||
| 159fb3cec6 | |||
| 4821934224 | |||
| ee39b88b00 | |||
| ce46d3b5f7 | |||
| 4a7811e06f | |||
| 3803c79c95 | |||
| 0160fa636d | |||
| 3684e83187 | |||
| 77c54b5c8a | |||
| bb80b0ecc1 | |||
| 1b59d6bf09 | |||
| eb1ad54db6 | |||
| 21ec3ad1c1 | |||
| 3a115908c4 | |||
| 5ff791642c | |||
| b803c7a90c | |||
| fb2fe67c23 | |||
| 51460558d4 | |||
| d105ceeb6b | |||
| c865aee766 | |||
| 273dfdfd09 | |||
| 1d1d8e50dc | |||
| 092afe67d2 | |||
| 2d9170705d | |||
| fdf9a951a4 | |||
| ca74029688 | |||
| 1a8fc1a670 | |||
| 19235f0791 | |||
| 61de7d8d33 | |||
| 8fb85ce56c | |||
| 1f98b6993d | |||
| f3a10d63d1 | |||
| 7a42bec63b | |||
| 44c421129e | |||
| ddff427926 | |||
| 00c8caade4 | |||
| 0209f49449 | |||
| 344c6ada6d | |||
| 11acd04419 | |||
| 8d49213b68 | |||
| 96911e3cf1 | |||
| 9950c28b9b | |||
| fa0f3538d1 | |||
| 2778f53aff | |||
| 37ac91d4f4 | |||
| 217f4a9a3b | |||
| 5d6a7437ed | |||
| 752a6cabee | |||
| 134ddc6154 | |||
| 28979c6b49 | |||
| b2066caa13 | |||
| 023c77d636 | |||
| 9bf3ec72cf | |||
| f359f5b1ce | |||
| 1c1e8fb190 | |||
| 54f83da3b8 | |||
| f8985c550f | |||
| e3d909e760 | |||
| 16a8df50c1 |
19
.env
Normal file
19
.env
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
DATABASE_URL="postgresql://bip:Production_123@localhost:5433/desa-darmasaba-v0.0.1?schema=public"
|
||||||
|
# Seafile
|
||||||
|
SEAFILE_TOKEN=20a19f4a04032215d50ce53292e6abdd38b9f806
|
||||||
|
SEAFILE_REPO_ID=f0e9ee4a-fd13-49a2-81c0-f253951d063a
|
||||||
|
SEAFILE_URL=https://cld-dkr-makuro-seafile.wibudev.com
|
||||||
|
SEAFILE_PUBLIC_SHARE_TOKEN=3a9a9ecb5e244f4da8ae
|
||||||
|
|
||||||
|
# Upload
|
||||||
|
WIBU_UPLOAD_DIR=uploads
|
||||||
|
WIBU_DOWNLOAD_DIR="./download"
|
||||||
|
NEXT_PUBLIC_BASE_URL="http://localhost:3000"
|
||||||
|
EMAIL_USER=nicoarya20@gmail.com
|
||||||
|
EMAIL_PASS=hymmfpcaqzqkfgbh
|
||||||
|
BASE_SESSION_KEY=kp9sGx91as0Kj2Ls81nAsl2Kdj13KsxP
|
||||||
|
BASE_TOKEN_KEY=Qm82JsA92lMnKw0291mxKaaP02KjslaA
|
||||||
|
|
||||||
|
# BOT-TELE
|
||||||
|
BOT_TOKEN=8498428675:AAEQwAUjTqpvgyyC5C123nP1mAxhOg12Ph0
|
||||||
|
CHAT_ID=5251328671
|
||||||
41
.env.example
Normal file
41
.env.example
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Database Configuration
|
||||||
|
DATABASE_URL="postgresql://username:password@localhost:5432/desa-darmasaba?schema=public"
|
||||||
|
|
||||||
|
# Seafile Configuration (File Storage)
|
||||||
|
SEAFILE_TOKEN=your_seafile_token
|
||||||
|
SEAFILE_REPO_ID=your_seafile_repo_id
|
||||||
|
SEAFILE_URL=https://your-seafile-instance.com
|
||||||
|
SEAFILE_PUBLIC_SHARE_TOKEN=your_seafile_public_share_token
|
||||||
|
|
||||||
|
# Upload Configuration
|
||||||
|
WIBU_UPLOAD_DIR=uploads
|
||||||
|
WIBU_DOWNLOAD_DIR=./download
|
||||||
|
|
||||||
|
# Application Configuration
|
||||||
|
# IMPORTANT: For staging/production, set this to your actual domain
|
||||||
|
# Local development: NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||||
|
# Staging: NEXT_PUBLIC_BASE_URL=https://desa-darmasaba-stg.wibudev.com
|
||||||
|
# Production: NEXT_PUBLIC_BASE_URL=https://your-production-domain.com
|
||||||
|
# Or use relative URL '/' for automatic protocol/domain detection (recommended)
|
||||||
|
NEXT_PUBLIC_BASE_URL=/
|
||||||
|
|
||||||
|
# Email Configuration (for notifications/subscriptions)
|
||||||
|
EMAIL_USER=your_email@gmail.com
|
||||||
|
EMAIL_PASS=your_email_app_password
|
||||||
|
|
||||||
|
# Session Configuration
|
||||||
|
BASE_SESSION_KEY=your_session_key_generate_secure_random_string
|
||||||
|
BASE_TOKEN_KEY=your_jwt_secret_key_generate_secure_random_string
|
||||||
|
|
||||||
|
# Telegram Bot Configuration (for notifications)
|
||||||
|
BOT_TOKEN=your_telegram_bot_token
|
||||||
|
CHAT_ID=your_telegram_chat_id
|
||||||
|
|
||||||
|
# Session Password (for iron-session)
|
||||||
|
SESSION_PASSWORD="your_session_password_min_32_characters_long_secure"
|
||||||
|
|
||||||
|
# ElevenLabs API Key (for TTS features - optional)
|
||||||
|
ELEVENLABS_API_KEY=your_elevenlabs_api_key
|
||||||
|
|
||||||
|
# Environment (optional, defaults to development)
|
||||||
|
# NODE_ENV=development
|
||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -29,7 +29,12 @@ yarn-error.log*
|
|||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# env
|
# env
|
||||||
.env*
|
# env local files (keep .env.example)
|
||||||
|
.env.local
|
||||||
|
.env*.local
|
||||||
|
.env.production
|
||||||
|
.env.development
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# QC
|
# QC
|
||||||
QC
|
QC
|
||||||
@@ -52,7 +57,5 @@ next-env.d.ts
|
|||||||
|
|
||||||
.github/
|
.github/
|
||||||
|
|
||||||
.env.*
|
|
||||||
|
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
|
|
||||||
|
|||||||
73
AUDIT_REPORT.md
Normal file
73
AUDIT_REPORT.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Engineering Audit Report: Desa Darmasaba
|
||||||
|
**Status:** Production Readiness Review (Critical)
|
||||||
|
**Auditor:** Staff Technical Architect
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Executive Summary & Scores
|
||||||
|
|
||||||
|
| Category | Score | Status |
|
||||||
|
| :--- | :---: | :--- |
|
||||||
|
| **Project Architecture** | 3/10 | 🔴 Critical Failure |
|
||||||
|
| **Code Quality** | 4/10 | 🟠 Poor |
|
||||||
|
| **Performance** | 5/10 | 🟡 Mediocre |
|
||||||
|
| **Security** | 5/10 | 🟠 Risk Detected |
|
||||||
|
| **Production Readiness** | 2/10 | 🔴 Not Ready |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ 1. Project Architecture
|
||||||
|
The project suffers from a **"Frankenstein Architecture"**. It attempts to run a full Elysia.js instance inside a Next.js Catch-All route.
|
||||||
|
- **Fractured Backend:** Logic is split between standard Next.js routes (`/api/auth`) and embedded Elysia modules.
|
||||||
|
- **Stateful Dependency:** Reliance on local filesystem (`WIBU_UPLOAD_DIR`) makes the application impossible to deploy on modern serverless platforms like Vercel.
|
||||||
|
- **Polluted Namespace:** Routing tree contains "test/coba" folders (`src/app/coba`, `src/app/percobaan`) that would be accessible in production.
|
||||||
|
|
||||||
|
## ⚛️ 2. Frontend Engineering (React / Next.js)
|
||||||
|
- **State Management Chaos:** Simultaneous use of `Valtio`, `Jotai`, `React Context`, and `localStorage`.
|
||||||
|
- **Tight Coupling:** Public pages (`/darmasaba`) import state directly from Admin internal states (`/admin/(dashboard)/_state`).
|
||||||
|
- **Heavy Client-Side Logic:** Logic that belongs in Server Actions or Hooks is embedded in presentational components (e.g., `Footer.tsx`).
|
||||||
|
|
||||||
|
## 📡 3. Backend / API Design
|
||||||
|
- **Framework Overhead:** Running Elysia inside Next.js adds unnecessary cold-boot overhead and complexity.
|
||||||
|
- **Weak Validation:** Widespread use of `as Type` casting in API handlers instead of runtime validation (Zod/Schema).
|
||||||
|
- **Service Integration:** OTP codes are sent via external `GET` requests with sensitive data in the query string—a major logging risk.
|
||||||
|
|
||||||
|
## 🗄️ 4. Database & Data Modeling (Prisma)
|
||||||
|
- **Schema Over-Normalization:** ~2000 lines of schema. Every minor content type (e.g., `LambangDesa`) is a separate table instead of a unified CMS model.
|
||||||
|
- **Polymorphic Monolith:** `FileStorage` is a "god table" with optional relations to ~40 other tables, creating a massive bottleneck and data integrity risk.
|
||||||
|
- **Connection Mismanagement:** Manual `prisma.$disconnect()` in API routes kills connection pooling performance.
|
||||||
|
|
||||||
|
## 🚀 5. Performance Engineering
|
||||||
|
- **Bypassing Optimization:** Custom `/api/utils/img` endpoint bypasses `next/image` optimization, serving uncompressed assets.
|
||||||
|
- **Aggressive Polling:** Client-side 30s polling for notifications is battery-draining and inefficient compared to SSE or SWR.
|
||||||
|
|
||||||
|
## 🔒 6. Security Audit
|
||||||
|
- **Insecure OTP Delivery:** Credentials passed as URL parameters to the WhatsApp service.
|
||||||
|
- **File Upload Risks:** Potential for Arbitrary File Upload due to direct local filesystem writes without rigorous sanitization.
|
||||||
|
|
||||||
|
## 🧹 7. Code Quality
|
||||||
|
- **Inconsistency:** Mixed English/Indonesian naming (e.g., `nomor` vs `createdAt`).
|
||||||
|
- **Artifacts:** Root directory is littered with scratch files: `xcoba.ts`, `xx.ts`, `test.txt`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚩 Top 10 Critical Problems
|
||||||
|
1. **Architectural Fracture:** Embedding Elysia inside Next.js creates a "split-brain" system.
|
||||||
|
2. **Serverless Incompatibility:** Dependency on local disk storage for uploads.
|
||||||
|
3. **Database Bloat:** Over-complicated schema with a fragile `FileStorage` monolith.
|
||||||
|
4. **State Fragmentation:** Mixed usage of Jotai and Valtio without a clear standard.
|
||||||
|
5. **Credential Leakage:** OTP codes sent via GET query parameters.
|
||||||
|
6. **Poor Cleanup:** Trial/Test folders and files committed to the production source.
|
||||||
|
7. **Asset Performance:** Bypassing Next.js image optimization.
|
||||||
|
8. **Coupling:** High dependency between public UI and internal Admin state.
|
||||||
|
9. **Type Safety:** Manual casting in APIs instead of runtime validation.
|
||||||
|
10. **Connection Pooling:** Inefficient Prisma connection management.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Tech Lead Refactoring Priorities
|
||||||
|
1. **Unify the API:** Decommission the Elysia wrapper. Port all logic to standard Next.js Route Handlers with Zod validation.
|
||||||
|
2. **Stateless Storage:** Implement an S3-compatible adapter for all file uploads. Remove `fs` usage.
|
||||||
|
3. **Schema Consolidation:** Refactor the schema to use generic content models where possible.
|
||||||
|
4. **Standardize State:** Choose one global state manager and migrate all components.
|
||||||
|
5. **Project Sanitization:** Delete all `coba`, `percobaan`, and scratch files (`xcoba.ts`, etc.).
|
||||||
60
Dockerfile
Normal file
60
Dockerfile
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Stage 1: Build
|
||||||
|
FROM oven/bun:1.3 AS build
|
||||||
|
|
||||||
|
# Install build dependencies for native modules
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
python3 \
|
||||||
|
make \
|
||||||
|
g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Set the working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package.json bun.lock* ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy the rest of the application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Use .env.example as default env for build
|
||||||
|
RUN cp .env.example .env
|
||||||
|
|
||||||
|
# Generate Prisma client
|
||||||
|
RUN bun x prisma generate
|
||||||
|
|
||||||
|
# Build the application frontend
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
# Stage 2: Runtime
|
||||||
|
FROM oven/bun:1.3-slim AS runtime
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# Install runtime dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
postgresql-client \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Set the working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy necessary files from build stage
|
||||||
|
COPY --from=build /app/package.json ./
|
||||||
|
COPY --from=build /app/tsconfig.json ./
|
||||||
|
COPY --from=build /app/.next ./.next
|
||||||
|
COPY --from=build /app/public ./public
|
||||||
|
COPY --from=build /app/src ./src
|
||||||
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/prisma ./prisma
|
||||||
|
|
||||||
|
# Expose the port
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
CMD ["bun", "start"]
|
||||||
173
MUSIK_CREATE_ANALYSIS.md
Normal file
173
MUSIK_CREATE_ANALYSIS.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
# Musik Desa - Create Feature Analysis
|
||||||
|
|
||||||
|
## Error Summary
|
||||||
|
**Error**: `ERR_BLOCKED_BY_CLIENT` saat create musik di staging environment
|
||||||
|
|
||||||
|
## Root Cause Analysis
|
||||||
|
|
||||||
|
### 1. **CORS Configuration Issue** (Primary)
|
||||||
|
File: `src/app/api/[[...slugs]]/route.ts`
|
||||||
|
|
||||||
|
The CORS configuration has specific origins listed:
|
||||||
|
```typescript
|
||||||
|
const corsConfig = {
|
||||||
|
origin: [
|
||||||
|
"http://localhost:3000",
|
||||||
|
"http://localhost:3001",
|
||||||
|
"https://cld-dkr-desa-darmasaba-stg.wibudev.com",
|
||||||
|
"https://cld-dkr-staging-desa-darmasaba.wibudev.com",
|
||||||
|
"*",
|
||||||
|
],
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem**: The wildcard `*` is at the end, but some browsers don't respect it when `credentials: true` is set.
|
||||||
|
|
||||||
|
### 2. **API Fetch Base URL** (Secondary)
|
||||||
|
File: `src/lib/api-fetch.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem**:
|
||||||
|
- In staging, this might still default to `http://localhost:3000`
|
||||||
|
- Mixed content (HTTPS frontend → HTTP API) gets blocked by browsers
|
||||||
|
- The `NEXT_PUBLIC_BASE_URL` environment variable might not be set in staging
|
||||||
|
|
||||||
|
### 3. **File Storage Upload Path** (Tertiary)
|
||||||
|
File: `src/app/api/[[...slugs]]/_lib/fileStorage/_lib/create.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const UPLOAD_DIR = process.env.WIBU_UPLOAD_DIR;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem**: If `WIBU_UPLOAD_DIR` is not set or points to a non-writable location, uploads will fail silently.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
### Fix 1: Update CORS Configuration
|
||||||
|
**File**: `src/app/api/[[...slugs]]/route.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Move wildcard to first position and ensure it works with credentials
|
||||||
|
const corsConfig = {
|
||||||
|
origin: [
|
||||||
|
"*", // Allow all origins (for staging flexibility)
|
||||||
|
"http://localhost:3000",
|
||||||
|
"http://localhost:3001",
|
||||||
|
"https://cld-dkr-desa-darmasaba-stg.wibudev.com",
|
||||||
|
"https://cld-dkr-staging-desa-darmasaba.wibudev.com",
|
||||||
|
"https://desa-darmasaba-stg.wibudev.com"
|
||||||
|
],
|
||||||
|
methods: ["GET", "POST", "PATCH", "DELETE", "PUT", "OPTIONS"] as HTTPMethod[],
|
||||||
|
allowedHeaders: ["Content-Type", "Authorization", "Accept"],
|
||||||
|
exposedHeaders: ["Content-Range", "X-Content-Range"],
|
||||||
|
maxAge: 86400, // 24 hours
|
||||||
|
credentials: true,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix 2: Add Environment Variable Validation
|
||||||
|
**File**: `.env.example` (update)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Application Configuration
|
||||||
|
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# For staging/production, set this to your actual domain
|
||||||
|
# NEXT_PUBLIC_BASE_URL=https://cld-dkr-desa-darmasaba-stg.wibudev.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix 3: Update API Fetch to Handle Relative URLs
|
||||||
|
**File**: `src/lib/api-fetch.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { AppServer } from '@/app/api/[[...slugs]]/route'
|
||||||
|
import { treaty } from '@elysiajs/eden'
|
||||||
|
|
||||||
|
// Use relative URL for better deployment flexibility
|
||||||
|
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || '/'
|
||||||
|
|
||||||
|
const ApiFetch = treaty<AppServer>(BASE_URL)
|
||||||
|
|
||||||
|
export default ApiFetch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix 4: Add Error Handling in Create Page
|
||||||
|
**File**: `src/app/admin/(dashboard)/musik/create/page.tsx`
|
||||||
|
|
||||||
|
Add better error logging to diagnose issues:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
// ... validation ...
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
// Upload cover image
|
||||||
|
const coverRes = await ApiFetch.api.fileStorage.create.post({
|
||||||
|
file: coverFile,
|
||||||
|
name: coverFile.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!coverRes.data?.data?.id) {
|
||||||
|
console.error('Cover upload failed:', coverRes);
|
||||||
|
return toast.error('Gagal mengunggah cover, silakan coba lagi');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... rest of the code ...
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating musik:', {
|
||||||
|
error,
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
toast.error('Terjadi kesalahan saat membuat musik');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
- [ ] Test create musik with cover image and audio file
|
||||||
|
- [ ] Verify CORS headers in browser DevTools Network tab
|
||||||
|
- [ ] Check that file uploads are saved to correct directory
|
||||||
|
|
||||||
|
### Staging Environment
|
||||||
|
- [ ] Set `NEXT_PUBLIC_BASE_URL` to staging domain
|
||||||
|
- [ ] Verify HTTPS is used for all API calls
|
||||||
|
- [ ] Check browser console for mixed content warnings
|
||||||
|
- [ ] Verify `WIBU_UPLOAD_DIR` is set and writable
|
||||||
|
- [ ] Test create musik end-to-end
|
||||||
|
|
||||||
|
## Additional Notes
|
||||||
|
|
||||||
|
### ERR_BLOCKED_BY_CLIENT Common Causes:
|
||||||
|
1. **CORS policy blocking** - Most likely cause
|
||||||
|
2. **Ad blockers** - Can block certain API endpoints
|
||||||
|
3. **Mixed content** - HTTPS page making HTTP requests
|
||||||
|
4. **Content Security Policy (CSP)** - Restrictive CSP headers
|
||||||
|
5. **Browser extensions** - Privacy/security extensions blocking requests
|
||||||
|
|
||||||
|
### Debugging Steps:
|
||||||
|
1. Open browser DevTools → Network tab
|
||||||
|
2. Try to create musik
|
||||||
|
3. Look for failed requests (red status)
|
||||||
|
4. Check the "Headers" tab for:
|
||||||
|
- Request URL (should be correct domain)
|
||||||
|
- Response headers (should have `Access-Control-Allow-Origin`)
|
||||||
|
- Status code (4xx/5xx indicates server-side issue)
|
||||||
|
5. Check browser console for CORS errors
|
||||||
|
|
||||||
|
## Recommended Next Steps
|
||||||
|
|
||||||
|
1. **Immediate**: Update CORS configuration to allow staging domain
|
||||||
|
2. **Short-term**: Add proper environment variable validation
|
||||||
|
3. **Long-term**: Implement proper error boundaries and logging
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
serverExternalPackages: ['@elysiajs/static', 'elysia'],
|
||||||
experimental: {},
|
experimental: {},
|
||||||
allowedDevOrigins: [
|
allowedDevOrigins: [
|
||||||
"http://192.168.1.82:3000", // buat akses dari HP/device lain
|
"http://192.168.1.82:3000", // buat akses dari HP/device lain
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
"@mantine/modals": "^8.3.6",
|
"@mantine/modals": "^8.3.6",
|
||||||
"@mantine/tiptap": "^7.17.4",
|
"@mantine/tiptap": "^7.17.4",
|
||||||
"@paljs/types": "^8.1.0",
|
"@paljs/types": "^8.1.0",
|
||||||
"@prisma/client": "^6.3.1",
|
"@prisma/client": "6.3.1",
|
||||||
"@tabler/icons-react": "^3.30.0",
|
"@tabler/icons-react": "^3.30.0",
|
||||||
"@tiptap/extension-highlight": "^2.11.7",
|
"@tiptap/extension-highlight": "^2.11.7",
|
||||||
"@tiptap/extension-link": "^2.11.7",
|
"@tiptap/extension-link": "^2.11.7",
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
"p-limit": "^6.2.0",
|
"p-limit": "^6.2.0",
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primereact": "^10.9.6",
|
"primereact": "^10.9.6",
|
||||||
"prisma": "^6.3.1",
|
"prisma": "6.3.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-exif-orientation-img": "^0.1.5",
|
"react-exif-orientation-img": "^0.1.5",
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ const ApbdesFormSchema = z.object({
|
|||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
deskripsi: z.string().optional(),
|
deskripsi: z.string().optional(),
|
||||||
jumlah: z.string().optional(),
|
jumlah: z.string().optional(),
|
||||||
imageId: z.string().min(1, "Gambar wajib diunggah"),
|
// Image dan file opsional (bisa kosong)
|
||||||
fileId: z.string().min(1, "File wajib diunggah"),
|
imageId: z.string().optional(),
|
||||||
|
fileId: z.string().optional(),
|
||||||
items: z.array(ApbdesItemSchema).min(1, "Minimal ada 1 item"),
|
items: z.array(ApbdesItemSchema).min(1, "Minimal ada 1 item"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -205,7 +205,6 @@ function EditAPBDes() {
|
|||||||
|
|
||||||
// Upload file baru jika ada perubahan
|
// Upload file baru jika ada perubahan
|
||||||
if (imageFile) {
|
if (imageFile) {
|
||||||
// Hapus file lama dari form jika ada file baru
|
|
||||||
const res = await ApiFetch.api.fileStorage.create.post({
|
const res = await ApiFetch.api.fileStorage.create.post({
|
||||||
file: imageFile,
|
file: imageFile,
|
||||||
name: imageFile.name,
|
name: imageFile.name,
|
||||||
@@ -217,7 +216,6 @@ function EditAPBDes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (docFile) {
|
if (docFile) {
|
||||||
// Hapus file lama dari form jika ada file baru
|
|
||||||
const res = await ApiFetch.api.fileStorage.create.post({
|
const res = await ApiFetch.api.fileStorage.create.post({
|
||||||
file: docFile,
|
file: docFile,
|
||||||
name: docFile.name,
|
name: docFile.name,
|
||||||
@@ -228,15 +226,7 @@ function EditAPBDes() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Jika tidak ada file baru, gunakan ID lama (sudah ada di form)
|
// Image dan file sekarang opsional, tidak perlu validasi
|
||||||
// Pastikan imageId dan fileId tetap ada
|
|
||||||
if (!apbdesState.edit.form.imageId) {
|
|
||||||
return toast.warn('Gambar wajib diunggah');
|
|
||||||
}
|
|
||||||
if (!apbdesState.edit.form.fileId) {
|
|
||||||
return toast.warn('Dokumen wajib diunggah');
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = await apbdesState.edit.update();
|
const success = await apbdesState.edit.update();
|
||||||
if (success) {
|
if (success) {
|
||||||
router.push('/admin/landing-page/apbdes');
|
router.push('/admin/landing-page/apbdes');
|
||||||
@@ -343,11 +333,11 @@ function EditAPBDes() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Gambar & Dokumen */}
|
{/* Gambar & Dokumen (Opsional) */}
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Box>
|
<Box>
|
||||||
<Text fw="bold" fz="sm" mb={6}>
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
Gambar APBDes
|
Gambar APBDes (Opsional)
|
||||||
</Text>
|
</Text>
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onDrop={handleDrop('image')}
|
onDrop={handleDrop('image')}
|
||||||
@@ -387,6 +377,7 @@ function EditAPBDes() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPreviewImage(null);
|
setPreviewImage(null);
|
||||||
setImageFile(null);
|
setImageFile(null);
|
||||||
|
apbdesState.edit.form.imageId = ''; // Clear imageId from form
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconX size={14} />
|
<IconX size={14} />
|
||||||
@@ -397,7 +388,7 @@ function EditAPBDes() {
|
|||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Text fw="bold" fz="sm" mb={6}>
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
Dokumen APBDes
|
Dokumen APBDes (Opsional)
|
||||||
</Text>
|
</Text>
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onDrop={handleDrop('doc')}
|
onDrop={handleDrop('doc')}
|
||||||
@@ -446,6 +437,7 @@ function EditAPBDes() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPreviewDoc(null);
|
setPreviewDoc(null);
|
||||||
setDocFile(null);
|
setDocFile(null);
|
||||||
|
apbdesState.edit.form.fileId = ''; // Clear fileId from form
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconX size={14} />
|
<IconX size={14} />
|
||||||
|
|||||||
@@ -46,13 +46,9 @@ function CreateAPBDes() {
|
|||||||
const [docFile, setDocFile] = useState<File | null>(null);
|
const [docFile, setDocFile] = useState<File | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
// Check if form is valid
|
// Check if form is valid - hanya cek items, gambar dan file opsional
|
||||||
const isFormValid = () => {
|
const isFormValid = () => {
|
||||||
return (
|
return stateAPBDes.create.form.items.length > 0;
|
||||||
imageFile !== null &&
|
|
||||||
docFile !== null &&
|
|
||||||
stateAPBDes.create.form.items.length > 0
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Form sementara untuk input item baru
|
// Form sementara untuk input item baru
|
||||||
@@ -84,28 +80,34 @@ function CreateAPBDes() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!imageFile || !docFile) {
|
|
||||||
return toast.warn("Pilih gambar dan dokumen terlebih dahulu");
|
|
||||||
}
|
|
||||||
if (stateAPBDes.create.form.items.length === 0) {
|
if (stateAPBDes.create.form.items.length === 0) {
|
||||||
return toast.warn("Minimal tambahkan 1 item APBDes");
|
return toast.warn("Minimal tambahkan 1 item APBDes");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
const [uploadImageRes, uploadDocRes] = await Promise.all([
|
|
||||||
ApiFetch.api.fileStorage.create.post({ file: imageFile, name: imageFile.name }),
|
|
||||||
ApiFetch.api.fileStorage.create.post({ file: docFile, name: docFile.name }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const imageId = uploadImageRes?.data?.data?.id;
|
// Upload files hanya jika ada file yang dipilih
|
||||||
const fileId = uploadDocRes?.data?.data?.id;
|
let imageId = '';
|
||||||
|
let fileId = '';
|
||||||
|
|
||||||
if (!imageId || !fileId) {
|
if (imageFile) {
|
||||||
return toast.error("Gagal mengupload file");
|
const uploadImageRes = await ApiFetch.api.fileStorage.create.post({
|
||||||
|
file: imageFile,
|
||||||
|
name: imageFile.name,
|
||||||
|
});
|
||||||
|
imageId = uploadImageRes?.data?.data?.id || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update form dengan ID file
|
if (docFile) {
|
||||||
|
const uploadDocRes = await ApiFetch.api.fileStorage.create.post({
|
||||||
|
file: docFile,
|
||||||
|
name: docFile.name,
|
||||||
|
});
|
||||||
|
fileId = uploadDocRes?.data?.data?.id || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update form dengan ID file (bisa kosong)
|
||||||
stateAPBDes.create.form.imageId = imageId;
|
stateAPBDes.create.form.imageId = imageId;
|
||||||
stateAPBDes.create.form.fileId = fileId;
|
stateAPBDes.create.form.fileId = fileId;
|
||||||
|
|
||||||
@@ -174,12 +176,16 @@ function CreateAPBDes() {
|
|||||||
style={{ border: '1px solid #e0e0e0' }}
|
style={{ border: '1px solid #e0e0e0' }}
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{/* Gambar & Dokumen (dipendekkan untuk fokus pada items) */}
|
{/* Info: File opsional */}
|
||||||
|
<Text fz="sm" c="dimmed" mb="xs">
|
||||||
|
* Upload gambar dan dokumen bersifat opsional. Bisa dikosongkan jika belum ada.
|
||||||
|
</Text>
|
||||||
|
|
||||||
<Stack gap={"xs"}>
|
<Stack gap={"xs"}>
|
||||||
{/* Gambar APBDes */}
|
{/* Gambar APBDes */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fw="bold" fz="sm" mb={6}>
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
Gambar APBDes
|
Gambar APBDes (Opsional)
|
||||||
</Text>
|
</Text>
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onDrop={(files) => {
|
onDrop={(files) => {
|
||||||
@@ -249,10 +255,10 @@ function CreateAPBDes() {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Dokumen APBDes */}
|
{/* Dokumen APBDes (Opsional) */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fw="bold" fz="sm" mb={6}>
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
Dokumen APBDes
|
Dokumen APBDes (Opsional)
|
||||||
</Text>
|
</Text>
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onDrop={(files) => {
|
onDrop={(files) => {
|
||||||
|
|||||||
@@ -211,6 +211,9 @@ function ListKategoriPrestasi({ search }: { search: string }) {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Modal Konfirmasi Hapus */}
|
{/* Modal Konfirmasi Hapus */}
|
||||||
<ModalKonfirmasiHapus
|
<ModalKonfirmasiHapus
|
||||||
opened={modalHapus}
|
opened={modalHapus}
|
||||||
|
|||||||
@@ -123,37 +123,51 @@ export default function CreateMusik() {
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
// Upload cover image
|
// Upload cover image
|
||||||
|
console.log('Uploading cover image:', coverFile.name);
|
||||||
const coverRes = await ApiFetch.api.fileStorage.create.post({
|
const coverRes = await ApiFetch.api.fileStorage.create.post({
|
||||||
file: coverFile,
|
file: coverFile,
|
||||||
name: coverFile.name,
|
name: coverFile.name,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('Cover upload response:', coverRes);
|
||||||
const coverUploaded = coverRes.data?.data;
|
const coverUploaded = coverRes.data?.data;
|
||||||
if (!coverUploaded?.id) {
|
if (!coverUploaded?.id) {
|
||||||
return toast.error('Gagal mengunggah cover, silakan coba lagi');
|
console.error('Cover upload failed:', coverRes);
|
||||||
|
toast.error('Gagal mengunggah cover, silakan coba lagi');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
musikState.musik.create.form.coverImageId = coverUploaded.id;
|
musikState.musik.create.form.coverImageId = coverUploaded.id;
|
||||||
|
|
||||||
// Upload audio file
|
// Upload audio file
|
||||||
|
console.log('Uploading audio file:', audioFile.name);
|
||||||
const audioRes = await ApiFetch.api.fileStorage.create.post({
|
const audioRes = await ApiFetch.api.fileStorage.create.post({
|
||||||
file: audioFile,
|
file: audioFile,
|
||||||
name: audioFile.name,
|
name: audioFile.name,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('Audio upload response:', audioRes);
|
||||||
const audioUploaded = audioRes.data?.data;
|
const audioUploaded = audioRes.data?.data;
|
||||||
if (!audioUploaded?.id) {
|
if (!audioUploaded?.id) {
|
||||||
return toast.error('Gagal mengunggah audio, silakan coba lagi');
|
console.error('Audio upload failed:', audioRes);
|
||||||
|
toast.error('Gagal mengunggah audio, silakan coba lagi');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
musikState.musik.create.form.audioFileId = audioUploaded.id;
|
musikState.musik.create.form.audioFileId = audioUploaded.id;
|
||||||
|
|
||||||
|
// Create musik entry
|
||||||
|
console.log('Creating musik entry with form:', musikState.musik.create.form);
|
||||||
await musikState.musik.create.create();
|
await musikState.musik.create.create();
|
||||||
|
|
||||||
resetForm();
|
resetForm();
|
||||||
router.push('/admin/musik');
|
router.push('/admin/musik');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating musik:', error);
|
console.error('Error creating musik:', {
|
||||||
|
error,
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
toast.error('Terjadi kesalahan saat membuat musik');
|
toast.error('Terjadi kesalahan saat membuat musik');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
|
|||||||
25
src/app/admin/(dashboard)/user&role/_com/getMenuIdByRole.ts
Normal file
25
src/app/admin/(dashboard)/user&role/_com/getMenuIdByRole.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// src/app/admin/_com/getMenuIdsByRoleId.ts
|
||||||
|
import { navBar, role1, role2, role3 } from '@/app/admin/_com/list_PageAdmin';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mengembalikan daftar ID menu (string[]) berdasarkan roleId
|
||||||
|
*/
|
||||||
|
export function getMenuIdsByRoleId(roleId: string | number): string[] {
|
||||||
|
const id = typeof roleId === 'string' ? parseInt(roleId, 10) : roleId;
|
||||||
|
|
||||||
|
switch (id) {
|
||||||
|
case 0:
|
||||||
|
// Asumsikan devBar ada dan punya struktur sama
|
||||||
|
return []; // atau sesuaikan jika ada devBar
|
||||||
|
case 1:
|
||||||
|
return navBar.map(section => section.id);
|
||||||
|
case 2:
|
||||||
|
return role1.map(section => section.id);
|
||||||
|
case 3:
|
||||||
|
return role2.map(section => section.id);
|
||||||
|
case 4:
|
||||||
|
return role3.map(section => section.id);
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,8 +17,8 @@ type FormCreate = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
deskripsi?: string;
|
deskripsi?: string;
|
||||||
jumlah?: string;
|
jumlah?: string;
|
||||||
imageId: string;
|
imageId?: string | null; // Opsional
|
||||||
fileId: string;
|
fileId?: string | null; // Opsional
|
||||||
items: APBDesItemInput[];
|
items: APBDesItemInput[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -32,12 +32,7 @@ export default async function apbdesCreate(context: Context) {
|
|||||||
if (!body.tahun) {
|
if (!body.tahun) {
|
||||||
throw new Error('Tahun is required');
|
throw new Error('Tahun is required');
|
||||||
}
|
}
|
||||||
if (!body.imageId) {
|
// Image dan file sekarang opsional
|
||||||
throw new Error('Image ID is required');
|
|
||||||
}
|
|
||||||
if (!body.fileId) {
|
|
||||||
throw new Error('File ID is required');
|
|
||||||
}
|
|
||||||
if (!body.items || body.items.length === 0) {
|
if (!body.items || body.items.length === 0) {
|
||||||
throw new Error('At least one item is required');
|
throw new Error('At least one item is required');
|
||||||
}
|
}
|
||||||
@@ -50,8 +45,8 @@ export default async function apbdesCreate(context: Context) {
|
|||||||
name: body.name || `APBDes Tahun ${body.tahun}`,
|
name: body.name || `APBDes Tahun ${body.tahun}`,
|
||||||
deskripsi: body.deskripsi,
|
deskripsi: body.deskripsi,
|
||||||
jumlah: body.jumlah,
|
jumlah: body.jumlah,
|
||||||
imageId: body.imageId,
|
imageId: body.imageId || null, // null jika tidak ada
|
||||||
fileId: body.fileId,
|
fileId: body.fileId || null, // null jika tidak ada
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ const APBDes = new Elysia({
|
|||||||
name: t.Optional(t.String()),
|
name: t.Optional(t.String()),
|
||||||
deskripsi: t.Optional(t.String()),
|
deskripsi: t.Optional(t.String()),
|
||||||
jumlah: t.Optional(t.String()),
|
jumlah: t.Optional(t.String()),
|
||||||
imageId: t.String(),
|
imageId: t.Optional(t.String()),
|
||||||
fileId: t.String(),
|
fileId: t.Optional(t.String()),
|
||||||
items: t.Array(ApbdesItemSchema),
|
items: t.Array(ApbdesItemSchema),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -50,8 +50,8 @@ const APBDes = new Elysia({
|
|||||||
name: t.Optional(t.String()),
|
name: t.Optional(t.String()),
|
||||||
deskripsi: t.Optional(t.String()),
|
deskripsi: t.Optional(t.String()),
|
||||||
jumlah: t.Optional(t.String()),
|
jumlah: t.Optional(t.String()),
|
||||||
imageId: t.String(),
|
imageId: t.Optional(t.String()),
|
||||||
fileId: t.String(),
|
fileId: t.Optional(t.String()),
|
||||||
items: t.Array(ApbdesItemSchema),
|
items: t.Array(ApbdesItemSchema),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { Context } from "elysia";
|
import { Context } from "elysia";
|
||||||
import { assignParentIdsToApbdesItems } from "./lib/getParentsID";
|
import { assignParentIdsToApbdesItems } from "./lib/getParentsID";
|
||||||
|
import { RealisasiItem } from "@prisma/client";
|
||||||
|
|
||||||
type APBDesItemInput = {
|
type APBDesItemInput = {
|
||||||
kode: string;
|
kode: string;
|
||||||
@@ -15,8 +16,8 @@ type FormUpdateBody = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
deskripsi?: string;
|
deskripsi?: string;
|
||||||
jumlah?: string;
|
jumlah?: string;
|
||||||
imageId: string;
|
imageId?: string | null;
|
||||||
fileId: string;
|
fileId?: string | null;
|
||||||
items: APBDesItemInput[];
|
items: APBDesItemInput[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,9 +50,9 @@ export default async function apbdesUpdate(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Build map untuk preserve realisasiItems berdasarkan kode
|
// 2. Build map untuk preserve realisasiItems berdasarkan kode
|
||||||
const existingItemsMap = new Map<string, {
|
const existingItemsMap = new Map<string, {
|
||||||
id: string;
|
id: string;
|
||||||
realisasiItems: any[];
|
realisasiItems: RealisasiItem[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
existing.items.forEach(item => {
|
existing.items.forEach(item => {
|
||||||
@@ -128,7 +129,7 @@ export default async function apbdesUpdate(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 8. Recalculate totalRealisasi setelah re-create realisasiItems
|
// 8. Recalculate totalRealisasi setelah re-create realisasiItems
|
||||||
for (const [kode, _] of existingItemsMap.entries()) {
|
for (const kode of existingItemsMap.keys()) {
|
||||||
const newItemId = newItemIdsMap.get(kode);
|
const newItemId = newItemIdsMap.get(kode);
|
||||||
if (newItemId) {
|
if (newItemId) {
|
||||||
const realisasiItems = await prisma.realisasiItem.findMany({
|
const realisasiItems = await prisma.realisasiItem.findMany({
|
||||||
@@ -168,8 +169,8 @@ export default async function apbdesUpdate(context: Context) {
|
|||||||
name: body.name || `APBDes Tahun ${body.tahun}`,
|
name: body.name || `APBDes Tahun ${body.tahun}`,
|
||||||
deskripsi: body.deskripsi,
|
deskripsi: body.deskripsi,
|
||||||
jumlah: body.jumlah,
|
jumlah: body.jumlah,
|
||||||
imageId: body.imageId,
|
imageId: body.imageId === '' ? null : body.imageId,
|
||||||
fileId: body.fileId,
|
fileId: body.fileId === '' ? null : body.fileId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import { getMenuIdsByRoleId } from "@/app/admin/(dashboard)/user&role/_com/getMenuIdByRole";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { Context } from "elysia";
|
import { Context } from "elysia";
|
||||||
|
|
||||||
@@ -34,11 +35,25 @@ export default async function userUpdate(context: Context) {
|
|||||||
const isActiveChanged =
|
const isActiveChanged =
|
||||||
isActive !== undefined && currentUser.isActive !== isActive;
|
isActive !== undefined && currentUser.isActive !== isActive;
|
||||||
|
|
||||||
// ✅ Jika role berubah, hapus semua akses menu yang ada
|
// ✅ Jika role berubah, reset dan set ulang akses menu
|
||||||
if (isRoleChanged) {
|
if (isRoleChanged && roleId) {
|
||||||
|
// Hapus akses lama
|
||||||
await prisma.userMenuAccess.deleteMany({
|
await prisma.userMenuAccess.deleteMany({
|
||||||
where: { userId: id }
|
where: { userId: id }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Ambil menu default untuk role baru
|
||||||
|
const menuIds = getMenuIdsByRoleId(roleId);
|
||||||
|
|
||||||
|
if (menuIds.length > 0) {
|
||||||
|
// Buat akses baru
|
||||||
|
await prisma.userMenuAccess.createMany({
|
||||||
|
data: menuIds.map(menuId => ({
|
||||||
|
userId: id,
|
||||||
|
menuId
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update user
|
// Update user
|
||||||
|
|||||||
@@ -47,15 +47,16 @@ fs.mkdir(UPLOAD_DIR_IMAGE, {
|
|||||||
|
|
||||||
const corsConfig = {
|
const corsConfig = {
|
||||||
origin: [
|
origin: [
|
||||||
|
"*", // Allow all origins - must be first when using credentials: true
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
"http://localhost:3001",
|
"http://localhost:3001",
|
||||||
"https://cld-dkr-desa-darmasaba-stg.wibudev.com",
|
"https://cld-dkr-desa-darmasaba-stg.wibudev.com",
|
||||||
"https://cld-dkr-staging-desa-darmasaba.wibudev.com",
|
"https://cld-dkr-staging-desa-darmasaba.wibudev.com",
|
||||||
"*", // Allow all origins in development
|
"https://desa-darmasaba-stg.wibudev.com",
|
||||||
],
|
],
|
||||||
methods: ["GET", "POST", "PATCH", "DELETE", "PUT", "OPTIONS"] as HTTPMethod[],
|
methods: ["GET", "POST", "PATCH", "DELETE", "PUT", "OPTIONS"] as HTTPMethod[],
|
||||||
allowedHeaders: ["Content-Type", "Authorization", "*"],
|
allowedHeaders: ["Content-Type", "Authorization", "Accept", "*"],
|
||||||
exposedHeaders: "*",
|
exposedHeaders: ["Content-Range", "X-Content-Range", "*"],
|
||||||
maxAge: 86400, // 24 hours
|
maxAge: 86400, // 24 hours
|
||||||
credentials: true,
|
credentials: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -82,6 +82,12 @@ export function MusicProvider({ children }: { children: ReactNode }) {
|
|||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
const isSeekingRef = useRef(false);
|
const isSeekingRef = useRef(false);
|
||||||
const animationFrameRef = useRef<number | null>(null);
|
const animationFrameRef = useRef<number | null>(null);
|
||||||
|
const isRepeatRef = useRef(false); // Ref untuk avoid stale closure
|
||||||
|
|
||||||
|
// Sync ref dengan state
|
||||||
|
useEffect(() => {
|
||||||
|
isRepeatRef.current = isRepeat;
|
||||||
|
}, [isRepeat]);
|
||||||
|
|
||||||
// Load musik data
|
// Load musik data
|
||||||
const loadMusikData = useCallback(async () => {
|
const loadMusikData = useCallback(async () => {
|
||||||
@@ -111,7 +117,8 @@ export function MusicProvider({ children }: { children: ReactNode }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
audioRef.current.addEventListener('ended', () => {
|
audioRef.current.addEventListener('ended', () => {
|
||||||
if (isRepeat) {
|
// Gunakan ref untuk avoid stale closure
|
||||||
|
if (isRepeatRef.current) {
|
||||||
audioRef.current!.currentTime = 0;
|
audioRef.current!.currentTime = 0;
|
||||||
audioRef.current!.play();
|
audioRef.current!.play();
|
||||||
} else {
|
} else {
|
||||||
@@ -132,7 +139,7 @@ export function MusicProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- playNext is intentionally not in deps to avoid circular dependency
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- playNext is intentionally not in deps to avoid circular dependency
|
||||||
}, [loadMusikData, isRepeat]);
|
}, [loadMusikData]); // Remove isRepeat dari deps karena sudah pakai ref
|
||||||
|
|
||||||
// Update time with requestAnimationFrame for smooth progress
|
// Update time with requestAnimationFrame for smooth progress
|
||||||
const updateTime = useCallback(() => {
|
const updateTime = useCallback(() => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
ActionIcon,
|
ActionIcon,
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
|
Button,
|
||||||
Flex,
|
Flex,
|
||||||
Group,
|
Group,
|
||||||
Paper,
|
Paper,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconArrowsShuffle,
|
IconArrowsShuffle,
|
||||||
|
IconMusic,
|
||||||
IconPlayerPauseFilled,
|
IconPlayerPauseFilled,
|
||||||
IconPlayerPlayFilled,
|
IconPlayerPlayFilled,
|
||||||
IconPlayerSkipBackFilled,
|
IconPlayerSkipBackFilled,
|
||||||
@@ -45,7 +47,7 @@ export default function FixedPlayerBar() {
|
|||||||
} = useMusic();
|
} = useMusic();
|
||||||
|
|
||||||
const [showVolume, setShowVolume] = useState(false);
|
const [showVolume, setShowVolume] = useState(false);
|
||||||
const [isPlayerVisible, setIsPlayerVisible] = useState(true);
|
const [isMinimized, setIsMinimized] = useState(false);
|
||||||
|
|
||||||
// Format time
|
// Format time
|
||||||
const formatTime = (seconds: number) => {
|
const formatTime = (seconds: number) => {
|
||||||
@@ -69,12 +71,55 @@ export default function FixedPlayerBar() {
|
|||||||
toggleShuffle();
|
toggleShuffle();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle close player
|
// Handle minimize player (show floating icon)
|
||||||
const handleClosePlayer = () => {
|
const handleMinimizePlayer = () => {
|
||||||
setIsPlayerVisible(false);
|
setIsMinimized(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!currentSong || !isPlayerVisible) {
|
// Handle restore player from floating icon
|
||||||
|
const handleRestorePlayer = () => {
|
||||||
|
setIsMinimized(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// If minimized, show floating icon instead of player bar
|
||||||
|
if (isMinimized) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Floating Music Icon - Shows when player is minimized */}
|
||||||
|
<Button
|
||||||
|
color="#0B4F78"
|
||||||
|
variant="filled"
|
||||||
|
size="md"
|
||||||
|
mt="md"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: '50%', // Menempatkan titik atas ikon di tengah layar
|
||||||
|
left: '0px',
|
||||||
|
transform: 'translateY(-50%)', // Menggeser ikon ke atas sebesar setengah tingginya sendiri agar benar-benar di tengah
|
||||||
|
borderBottomRightRadius: '20px',
|
||||||
|
borderTopRightRadius: '20px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'transform 0.2s ease',
|
||||||
|
zIndex: 1
|
||||||
|
}}
|
||||||
|
onClick={handleRestorePlayer}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(-50%) scale(1.1)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(-50%)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconMusic size={28} color="white" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Spacer to prevent content from being hidden behind player */}
|
||||||
|
<Box h={20} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentSong) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,12 +134,12 @@ export default function FixedPlayerBar() {
|
|||||||
p="sm"
|
p="sm"
|
||||||
shadow="lg"
|
shadow="lg"
|
||||||
style={{
|
style={{
|
||||||
zIndex: 1000,
|
zIndex: 1,
|
||||||
borderTop: '1px solid rgba(0,0,0,0.1)',
|
borderTop: '1px solid rgba(0,0,0,0.1)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Flex align="center" gap="md" justify="space-between">
|
<Flex align="center" gap="md" justify="space-between">
|
||||||
{/* Song Info */}
|
{/* Song Info - Left */}
|
||||||
<Group gap="sm" flex={1} style={{ minWidth: 0 }}>
|
<Group gap="sm" flex={1} style={{ minWidth: 0 }}>
|
||||||
<Avatar
|
<Avatar
|
||||||
src={currentSong.coverImage?.link || ''}
|
src={currentSong.coverImage?.link || ''}
|
||||||
@@ -113,78 +158,81 @@ export default function FixedPlayerBar() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls + Progress - Center */}
|
||||||
<Group gap="xs">
|
<Group gap="xs" flex={2} justify="center">
|
||||||
<ActionIcon
|
{/* Control Buttons */}
|
||||||
variant={isShuffle ? 'filled' : 'subtle'}
|
<Group gap="xs">
|
||||||
color={isShuffle ? 'blue' : 'gray'}
|
<ActionIcon
|
||||||
size="lg"
|
variant={isShuffle ? 'filled' : 'subtle'}
|
||||||
onClick={handleToggleShuffle}
|
color={isShuffle ? 'blue' : 'gray'}
|
||||||
title="Shuffle"
|
size="lg"
|
||||||
>
|
onClick={handleToggleShuffle}
|
||||||
<IconArrowsShuffle size={18} />
|
title="Shuffle"
|
||||||
</ActionIcon>
|
>
|
||||||
|
<IconArrowsShuffle size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
size="lg"
|
size="lg"
|
||||||
onClick={playPrev}
|
onClick={playPrev}
|
||||||
title="Previous"
|
title="Previous"
|
||||||
>
|
>
|
||||||
<IconPlayerSkipBackFilled size={20} />
|
<IconPlayerSkipBackFilled size={20} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="filled"
|
variant="filled"
|
||||||
color={isPlaying ? 'blue' : 'gray'}
|
color={isPlaying ? 'blue' : 'gray'}
|
||||||
size="xl"
|
size="xl"
|
||||||
radius="xl"
|
radius="xl"
|
||||||
onClick={togglePlayPause}
|
onClick={togglePlayPause}
|
||||||
title={isPlaying ? 'Pause' : 'Play'}
|
title={isPlaying ? 'Pause' : 'Play'}
|
||||||
>
|
>
|
||||||
{isPlaying ? (
|
{isPlaying ? (
|
||||||
<IconPlayerPauseFilled size={24} />
|
<IconPlayerPauseFilled size={24} />
|
||||||
) : (
|
) : (
|
||||||
<IconPlayerPlayFilled size={24} />
|
<IconPlayerPlayFilled size={24} />
|
||||||
)}
|
)}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
size="lg"
|
size="lg"
|
||||||
onClick={playNext}
|
onClick={playNext}
|
||||||
title="Next"
|
title="Next"
|
||||||
>
|
>
|
||||||
<IconPlayerSkipForwardFilled size={20} />
|
<IconPlayerSkipForwardFilled size={20} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color={isRepeat ? 'blue' : 'gray'}
|
color={isRepeat ? 'blue' : 'gray'}
|
||||||
size="lg"
|
size="lg"
|
||||||
onClick={toggleRepeat}
|
onClick={toggleRepeat}
|
||||||
title={isRepeat ? 'Repeat On' : 'Repeat Off'}
|
title={isRepeat ? 'Repeat On' : 'Repeat Off'}
|
||||||
>
|
>
|
||||||
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
|
{isRepeat ? <IconRepeat size={18} /> : <IconRepeatOff size={18} />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Progress Bar - Desktop */}
|
||||||
|
<Box w={200} display={{ base: 'none', md: 'block' }}>
|
||||||
|
<Slider
|
||||||
|
value={currentTime}
|
||||||
|
max={duration || 100}
|
||||||
|
onChange={handleSeek}
|
||||||
|
size="sm"
|
||||||
|
color="blue"
|
||||||
|
label={(value) => formatTime(value)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Progress Bar - Desktop */}
|
{/* Right Controls - Volume + Close */}
|
||||||
<Box w={200} display={{ base: 'none', md: 'block' }}>
|
<Group gap="xs" flex={1} justify="flex-end">
|
||||||
<Slider
|
|
||||||
value={currentTime}
|
|
||||||
max={duration || 100}
|
|
||||||
onChange={handleSeek}
|
|
||||||
size="sm"
|
|
||||||
color="blue"
|
|
||||||
label={(value) => formatTime(value)}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Right Controls */}
|
|
||||||
<Group gap="xs">
|
|
||||||
<Box
|
<Box
|
||||||
onMouseEnter={() => setShowVolume(true)}
|
onMouseEnter={() => setShowVolume(true)}
|
||||||
onMouseLeave={() => setShowVolume(false)}
|
onMouseLeave={() => setShowVolume(false)}
|
||||||
@@ -241,8 +289,8 @@ export default function FixedPlayerBar() {
|
|||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
size="lg"
|
size="lg"
|
||||||
onClick={handleClosePlayer}
|
onClick={handleMinimizePlayer}
|
||||||
title="Close player"
|
title="Minimize player"
|
||||||
>
|
>
|
||||||
<IconX size={18} />
|
<IconX size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { Button } from '@mantine/core';
|
import { Button } from '@mantine/core';
|
||||||
import { IconMusic, IconMusicOff } from '@tabler/icons-react';
|
import { IconDisabled, IconDisabledOff } from '@tabler/icons-react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
const NewsReaderLanding = () => {
|
const NewsReaderLanding = () => {
|
||||||
@@ -95,15 +95,17 @@ const NewsReaderLanding = () => {
|
|||||||
mt="md"
|
mt="md"
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
bottom: '350px',
|
top: '50%', // Menempatkan titik atas ikon di tengah layar
|
||||||
left: '0px',
|
left: '0px',
|
||||||
|
transform: 'translateY(80%)', // Menggeser ikon ke atas sebesar setengah tingginya sendiri agar benar-benar di tengah
|
||||||
borderBottomRightRadius: '20px',
|
borderBottomRightRadius: '20px',
|
||||||
borderTopRightRadius: '20px',
|
borderTopRightRadius: '20px',
|
||||||
transition: 'all 0.3s ease',
|
cursor: 'pointer',
|
||||||
|
transition: 'transform 0.2s',
|
||||||
zIndex: 1
|
zIndex: 1
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isPointerMode ? <IconMusicOff /> : <IconMusic />}
|
{isPointerMode ? <IconDisabledOff /> : <IconDisabled />}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,27 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { Paper, Title, Progress, Stack, Text, Group, Box } from '@mantine/core';
|
import { Paper, Title, Progress, Stack, Text, Group, Box } from '@mantine/core';
|
||||||
|
|
||||||
function Summary({ title, data }: any) {
|
interface APBDesItem {
|
||||||
|
tipe: string | null;
|
||||||
|
anggaran: number;
|
||||||
|
realisasi?: number;
|
||||||
|
totalRealisasi?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SummaryProps {
|
||||||
|
title: string;
|
||||||
|
data: APBDesItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function Summary({ title, data }: SummaryProps) {
|
||||||
if (!data || data.length === 0) return null;
|
if (!data || data.length === 0) return null;
|
||||||
|
|
||||||
const totalAnggaran = data.reduce((s: number, i: any) => s + i.anggaran, 0);
|
const totalAnggaran = data.reduce((s: number, i: APBDesItem) => s + i.anggaran, 0);
|
||||||
// Use realisasi field (already mapped from totalRealisasi in transformAPBDesData)
|
// Use realisasi field (already mapped from totalRealisasi in transformAPBDesData)
|
||||||
const totalRealisasi = data.reduce((s: number, i: any) => s + (i.realisasi || i.totalRealisasi || 0), 0);
|
const totalRealisasi = data.reduce(
|
||||||
|
(s: number, i: APBDesItem) => s + (i.realisasi || i.totalRealisasi || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
const persen =
|
const persen =
|
||||||
totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0;
|
totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0;
|
||||||
@@ -78,28 +93,21 @@ function Summary({ title, data }: any) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GrafikRealisasi({ apbdesData }: any) {
|
export default function GrafikRealisasi({
|
||||||
const items = apbdesData.items || [];
|
apbdesData,
|
||||||
const tahun = apbdesData.tahun || new Date().getFullYear();
|
}: {
|
||||||
|
apbdesData: {
|
||||||
const pendapatan = items.filter((i: any) => i.tipe === 'pendapatan');
|
tahun?: number | null;
|
||||||
const belanja = items.filter((i: any) => i.tipe === 'belanja');
|
items?: APBDesItem[] | null;
|
||||||
const pembiayaan = items.filter((i: any) => i.tipe === 'pembiayaan');
|
[key: string]: any;
|
||||||
|
|
||||||
// Hitung total keseluruhan
|
|
||||||
const totalAnggaranSemua = items.reduce((s: number, i: any) => s + i.anggaran, 0);
|
|
||||||
// Use realisasi field (already mapped from totalRealisasi in transformAPBDesData)
|
|
||||||
const totalRealisasiSemua = items.reduce((s: number, i: any) => s + (i.realisasi || i.totalRealisasi || 0), 0);
|
|
||||||
const persenSemua = totalAnggaranSemua > 0 ? (totalRealisasiSemua / totalAnggaranSemua) * 100 : 0;
|
|
||||||
|
|
||||||
const formatRupiah = (angka: number) => {
|
|
||||||
return new Intl.NumberFormat('id-ID', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'IDR',
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(angka);
|
|
||||||
};
|
};
|
||||||
|
}) {
|
||||||
|
const items = apbdesData?.items || [];
|
||||||
|
const tahun = apbdesData?.tahun || new Date().getFullYear();
|
||||||
|
|
||||||
|
const pendapatan = items.filter((i: APBDesItem) => i.tipe === 'pendapatan');
|
||||||
|
const belanja = items.filter((i: APBDesItem) => i.tipe === 'belanja');
|
||||||
|
const pembiayaan = items.filter((i: APBDesItem) => i.tipe === 'pembiayaan');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper withBorder p="md" radius="md">
|
<Paper withBorder p="md" radius="md">
|
||||||
@@ -112,27 +120,6 @@ export default function GrafikRealisasi({ apbdesData }: any) {
|
|||||||
<Summary title="💸 Belanja" data={belanja} />
|
<Summary title="💸 Belanja" data={belanja} />
|
||||||
<Summary title="📊 Pembiayaan" data={pembiayaan} />
|
<Summary title="📊 Pembiayaan" data={pembiayaan} />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{/* Summary Total Keseluruhan */}
|
|
||||||
<Box p="md" bg="gray.0">
|
|
||||||
<>
|
|
||||||
<Group justify="space-between" mb="xs">
|
|
||||||
<Text fw={700} fz="lg">TOTAL KESELURUHAN</Text>
|
|
||||||
<Text fw={700} fz="xl" c={persenSemua >= 100 ? 'teal' : persenSemua >= 80 ? 'blue' : 'red'}>
|
|
||||||
{persenSemua.toFixed(2)}%
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
<Text fz="sm" c="dimmed" mb="xs">
|
|
||||||
{formatRupiah(totalRealisasiSemua)} / {formatRupiah(totalAnggaranSemua)}
|
|
||||||
</Text>
|
|
||||||
<Progress
|
|
||||||
value={persenSemua}
|
|
||||||
size="lg"
|
|
||||||
radius="xl"
|
|
||||||
color={persenSemua >= 100 ? 'teal' : persenSemua >= 80 ? 'blue' : 'red'}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import "@mantine/core/styles.css";
|
import "@mantine/core/styles.css";
|
||||||
import "./globals.css";
|
import "./globals.css"; // Sisanya import di globals.css
|
||||||
|
|
||||||
import LoadDataFirstClient from "@/app/darmasaba/_com/LoadDataFirstClient";
|
import LoadDataFirstClient from "@/app/darmasaba/_com/LoadDataFirstClient";
|
||||||
import { MusicProvider } from "@/app/context/MusicContext";
|
import { MusicProvider } from "@/app/context/MusicContext";
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
MantineProvider,
|
MantineProvider,
|
||||||
createTheme,
|
createTheme,
|
||||||
mantineHtmlProps,
|
mantineHtmlProps,
|
||||||
|
// mantineHtmlProps,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { Metadata, Viewport } from "next";
|
import { Metadata, Viewport } from "next";
|
||||||
import { ViewTransitions } from "next-view-transitions";
|
import { ViewTransitions } from "next-view-transitions";
|
||||||
@@ -98,13 +99,13 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ViewTransitions>
|
<html lang="id" {...mantineHtmlProps}>
|
||||||
<html lang="id" {...mantineHtmlProps}>
|
<head>
|
||||||
<head>
|
<meta charSet="utf-8" />
|
||||||
<meta charSet="utf-8" />
|
<ColorSchemeScript defaultColorScheme="light" />
|
||||||
<ColorSchemeScript defaultColorScheme="light" />
|
</head>
|
||||||
</head>
|
<body>
|
||||||
<body>
|
<ViewTransitions>
|
||||||
<MusicProvider>
|
<MusicProvider>
|
||||||
<MantineProvider theme={theme} defaultColorScheme="light">
|
<MantineProvider theme={theme} defaultColorScheme="light">
|
||||||
{children}
|
{children}
|
||||||
@@ -116,8 +117,8 @@ export default function RootLayout({
|
|||||||
/>
|
/>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
</MusicProvider>
|
</MusicProvider>
|
||||||
</body>
|
</ViewTransitions>
|
||||||
</html>
|
</body>
|
||||||
</ViewTransitions>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,7 @@ function Page() {
|
|||||||
dengan ketentuan ini, harap jangan gunakan Website.
|
dengan ketentuan ini, harap jangan gunakan Website.
|
||||||
</Text>
|
</Text>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Title order={2} size="h2" fw={700} c="blue.9" mb="md">
|
<Title order={2} size="h2" fw={700} c="blue.9" mb="md">
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { AppServer } from '@/app/api/[[...slugs]]/route'
|
import { AppServer } from '@/app/api/[[...slugs]]/route'
|
||||||
import { treaty } from '@elysiajs/eden'
|
import { treaty } from '@elysiajs/eden'
|
||||||
|
|
||||||
// const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'localhost:3000'
|
// Use relative URL '/' for better deployment flexibility
|
||||||
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'
|
// This allows the API to work correctly in both development and staging/production
|
||||||
|
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || '/'
|
||||||
|
|
||||||
const ApiFetch = treaty<AppServer>(BASE_URL)
|
const ApiFetch = treaty<AppServer>(BASE_URL)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user