Compare commits
48 Commits
nico/3-feb
...
test-model
| Author | SHA1 | Date | |
|---|---|---|---|
| 6712da9ac2 | |||
| ac11a9367c | |||
| 67e5ceb254 | |||
| 65942ac9d2 | |||
| e0436cc384 | |||
| 63682e47b6 | |||
| f4705690a9 | |||
| 239771a714 | |||
| 03451195c8 | |||
| 597af7e716 | |||
| 0a8a026b94 | |||
| a5bd91b580 | |||
| ae3187804e | |||
| 91e32f3f1c | |||
| 4d03908f23 | |||
| 0563f9664f | |||
| 961cc32057 | |||
| fe7672e09f | |||
| 341ff5779f | |||
| 69f7b4c162 | |||
| 409ad4f1a2 | |||
| 55ea3c473a | |||
| a152eaf984 | |||
| 223b85a714 | |||
| f1729151b3 | |||
| 8e8c133eea | |||
| 1e7acac193 | |||
| 42dcbcfb22 | |||
| 22de1aa1f3 | |||
| b1d28a8322 | |||
| b86a3a85c3 | |||
| fd63bb0fd4 | |||
| f2c9a922a6 | |||
| 92b24440fe | |||
| f0558aa0d0 | |||
| 8132609ccb | |||
| 1ddc1d7eac | |||
| aa354992e7 | |||
| d43b07c2ef | |||
| 9678e6979b | |||
| b35874b120 | |||
| b69df2454e | |||
| df198c320a | |||
| f550e29a75 | |||
| bb7384f1e5 | |||
| df154806f7 | |||
| 25000d0b0f | |||
| bbd52fb6f5 |
65
.gemini/hooks/telegram-notify.ts
Executable file
65
.gemini/hooks/telegram-notify.ts
Executable file
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
// Fungsi untuk mencari string terpanjang dalam objek (biasanya balasan AI)
|
||||||
|
function findLongestString(obj: any): string {
|
||||||
|
let longest = "";
|
||||||
|
const search = (item: any) => {
|
||||||
|
if (typeof item === "string") {
|
||||||
|
if (item.length > longest.length) longest = item;
|
||||||
|
} else if (Array.isArray(item)) {
|
||||||
|
item.forEach(search);
|
||||||
|
} else if (item && typeof item === "object") {
|
||||||
|
Object.values(item).forEach(search);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
search(obj);
|
||||||
|
return longest;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
const inputRaw = readFileSync(0, "utf-8");
|
||||||
|
if (!inputRaw) return;
|
||||||
|
const input = JSON.parse(inputRaw);
|
||||||
|
|
||||||
|
// DEBUG: Lihat struktur asli di console terminal (stderr)
|
||||||
|
console.error("DEBUG KEYS:", Object.keys(input));
|
||||||
|
|
||||||
|
const BOT_TOKEN = process.env.BOT_TOKEN;
|
||||||
|
const CHAT_ID = process.env.CHAT_ID;
|
||||||
|
|
||||||
|
const sessionId = input.session_id || "unknown";
|
||||||
|
|
||||||
|
// Cari teks secara otomatis di seluruh objek JSON
|
||||||
|
let finalText = findLongestString(input.response || input);
|
||||||
|
|
||||||
|
if (!finalText || finalText.length < 5) {
|
||||||
|
finalText =
|
||||||
|
"Teks masih gagal diekstraksi. Struktur: " +
|
||||||
|
Object.keys(input).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const message =
|
||||||
|
`✅ *Gemini Task Selesai*\n\n` +
|
||||||
|
`🆔 Session: \`${sessionId}\` \n\n` +
|
||||||
|
`🧠 Output:\n${finalText.substring(0, 3500)}`;
|
||||||
|
|
||||||
|
await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
chat_id: CHAT_ID,
|
||||||
|
text: message,
|
||||||
|
parse_mode: "Markdown",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
process.stdout.write(JSON.stringify({ status: "continue" }));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Hook Error:", err);
|
||||||
|
process.stdout.write(JSON.stringify({ status: "continue" }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
17
.gemini/settings.json
Normal file
17
.gemini/settings.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"AfterAgent": [
|
||||||
|
{
|
||||||
|
"matcher": "*",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"name": "telegram-notify",
|
||||||
|
"type": "command",
|
||||||
|
"command": "bun $GEMINI_PROJECT_DIR/.gemini/hooks/telegram-notify.ts",
|
||||||
|
"timeout": 10000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -31,6 +31,9 @@ yarn-error.log*
|
|||||||
# env
|
# env
|
||||||
.env*
|
.env*
|
||||||
|
|
||||||
|
# QC
|
||||||
|
QC
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
|
|||||||
62
GEMINI.md
Normal file
62
GEMINI.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Project: Desa Darmasaba
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
The `desa-darmasaba` project is a Next.js (version 15+) application developed with TypeScript. It serves as an official platform for Desa Darmasaba (a village in Badung, Bali), offering various public services, news, and detailed village profiles.
|
||||||
|
|
||||||
|
**Key Technologies:**
|
||||||
|
|
||||||
|
* **Frontend Framework:** Next.js (v15+) with React (v19+)
|
||||||
|
* **Language:** TypeScript
|
||||||
|
* **UI Library:** Mantine UI
|
||||||
|
* **Database ORM:** Prisma (v6+)
|
||||||
|
* **Database:** PostgreSQL (as configured in `prisma/schema.prisma`)
|
||||||
|
* **API Framework:** Elysia (used for API routes, as seen in dependencies)
|
||||||
|
* **State Management:** Potentially Jotai and Valtio (listed in dependencies)
|
||||||
|
* **Image Processing:** Sharp
|
||||||
|
* **Package Manager:** Likely Bun, given `bun.lockb` and the `prisma:seed` script.
|
||||||
|
|
||||||
|
The application architecture follows the Next.js App Router structure, with comprehensive data models defined in `prisma/schema.prisma` covering various domains like public information, health, security, economy, innovation, environment, and education. It also includes configurations for image handling and caching.
|
||||||
|
|
||||||
|
## Building and Running
|
||||||
|
|
||||||
|
This project uses `bun` as the package manager. Ensure Bun is installed to run these commands.
|
||||||
|
|
||||||
|
* **Install Dependencies:**
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
* **Development Server:**
|
||||||
|
Runs the Next.js development server.
|
||||||
|
```bash
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
* **Build for Production:**
|
||||||
|
Builds the Next.js application for production deployment.
|
||||||
|
```bash
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
|
* **Start Production Server:**
|
||||||
|
Starts the Next.js application in production mode.
|
||||||
|
```bash
|
||||||
|
bun run start
|
||||||
|
```
|
||||||
|
|
||||||
|
* **Database Seeding:**
|
||||||
|
Executes the Prisma seeding script to populate the database.
|
||||||
|
```bash
|
||||||
|
bun run prisma:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Conventions
|
||||||
|
|
||||||
|
* **Coding Language:** TypeScript is strictly enforced.
|
||||||
|
* **Frontend Framework:** Next.js App Router for page and component structuring.
|
||||||
|
* **UI/UX:** Adherence to Mantine UI component library for consistent styling and user experience.
|
||||||
|
* **Database Interaction:** Prisma ORM is used for all database operations, with a PostgreSQL database.
|
||||||
|
* **Linting:** ESLint is configured with `next/core-web-vitals` and `next/typescript` to maintain code quality and adherence to Next.js and TypeScript best practices.
|
||||||
|
* **Styling:** PostCSS is used, with `postcss-preset-mantine` and `postcss-simple-vars` defining Mantine-specific breakpoints and other CSS variables.
|
||||||
|
* **Imports:** Absolute imports are configured using `@/*` which resolves to the `src/` directory.
|
||||||
232
QWEN.md
232
QWEN.md
@@ -0,0 +1,232 @@
|
|||||||
|
# Desa Darmasaba - Village Management System
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Desa Darmasaba is a comprehensive Next.js 15 application designed for village management services in Darmasaba, Badung, Bali. The application serves as a digital platform for government services, public information, and community engagement. It features multiple sections including PPID (Public Information Disclosure), health services, security, education, environment, economy, innovation, and more.
|
||||||
|
|
||||||
|
### Key Technologies
|
||||||
|
- **Framework**: Next.js 15 with App Router
|
||||||
|
- **Language**: TypeScript with strict mode
|
||||||
|
- **Styling**: Mantine UI components with custom CSS
|
||||||
|
- **Backend**: Elysia.js API server integrated with Next.js
|
||||||
|
- **Database**: PostgreSQL with Prisma ORM
|
||||||
|
- **State Management**: Valtio for global state
|
||||||
|
- **Authentication**: JWT with iron-session
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
The application follows a modular architecture with:
|
||||||
|
- A main frontend built with Next.js and Mantine UI
|
||||||
|
- An integrated Elysia.js API server for backend operations
|
||||||
|
- Prisma ORM for database interactions
|
||||||
|
- File storage integration with Seafile
|
||||||
|
- Multiple domain-specific modules (PPID, health, security, education, etc.)
|
||||||
|
|
||||||
|
## Building and Running
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Node.js (with Bun runtime)
|
||||||
|
- PostgreSQL database
|
||||||
|
- Seafile server for file storage
|
||||||
|
|
||||||
|
### Setup Instructions
|
||||||
|
1. Install dependencies:
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Set up environment variables in `.env.local`:
|
||||||
|
```
|
||||||
|
DATABASE_URL=your_postgresql_connection_string
|
||||||
|
SEAFILE_TOKEN=your_seafile_token
|
||||||
|
SEAFILE_REPO_ID=your_seafile_repo_id
|
||||||
|
SEAFILE_BASE_URL=your_seafile_base_url
|
||||||
|
SEAFILE_PUBLIC_SHARE_TOKEN=your_seafile_public_share_token
|
||||||
|
SEAFILE_URL=your_seafile_api_url
|
||||||
|
WIBU_UPLOAD_DIR=your_upload_directory
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Generate Prisma client:
|
||||||
|
```bash
|
||||||
|
bunx prisma generate
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Push database schema:
|
||||||
|
```bash
|
||||||
|
bunx prisma db push
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Seed the database:
|
||||||
|
```bash
|
||||||
|
bun run prisma/seed.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Run the development server:
|
||||||
|
```bash
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available Scripts
|
||||||
|
- `bun run dev` - Start development server
|
||||||
|
- `bun run build` - Build for production
|
||||||
|
- `bun run start` - Start production server
|
||||||
|
- `bun run prisma/seed.ts` - Run database seeding
|
||||||
|
- `bunx prisma generate` - Generate Prisma client
|
||||||
|
- `bunx prisma db push` - Push schema changes to database
|
||||||
|
- `bunx prisma studio` - Open Prisma Studio GUI
|
||||||
|
|
||||||
|
## Development Conventions
|
||||||
|
|
||||||
|
### Code Structure
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/ # Next.js app router pages
|
||||||
|
│ ├── admin/ # Admin dashboard pages
|
||||||
|
│ ├── api/ # API routes with Elysia.js
|
||||||
|
│ ├── darmasaba/ # Public-facing village pages
|
||||||
|
│ └── ...
|
||||||
|
├── con/ # Constants and configuration
|
||||||
|
├── hooks/ # React hooks
|
||||||
|
├── lib/ # Utility functions and configurations
|
||||||
|
├── middlewares/ # Next.js middleware
|
||||||
|
├── state/ # Global state management
|
||||||
|
├── store/ # Additional state management
|
||||||
|
├── types/ # TypeScript type definitions
|
||||||
|
└── utils/ # Utility functions
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import Conventions
|
||||||
|
- Use absolute imports with `@/` alias (configured in tsconfig.json)
|
||||||
|
- Group imports: external libraries first, then internal modules
|
||||||
|
- Keep import statements organized and remove unused imports
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// External libraries
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button, Stack } from '@mantine/core'
|
||||||
|
|
||||||
|
// Internal modules
|
||||||
|
import ApiFetch from '@/lib/api-fetch'
|
||||||
|
import { MyComponent } from '@/components/my-component'
|
||||||
|
```
|
||||||
|
|
||||||
|
### TypeScript Configuration
|
||||||
|
- Strict mode enabled (`"strict": true`)
|
||||||
|
- Target: ES2017
|
||||||
|
- Module resolution: bundler
|
||||||
|
- Path alias: `@/*` maps to `./src/*`
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
- **Components**: PascalCase (e.g., `UploadImage.tsx`)
|
||||||
|
- **Files**: kebab-case for utilities (e.g., `api-fetch.ts`)
|
||||||
|
- **Variables/Functions**: camelCase
|
||||||
|
- **Constants**: UPPER_SNAKE_CASE
|
||||||
|
- **Database Models**: PascalCase (Prisma convention)
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
- Use try-catch blocks for async operations
|
||||||
|
- Implement proper error boundaries in React components
|
||||||
|
- Log errors appropriately without exposing sensitive data
|
||||||
|
- Use Zod for runtime validation and type safety
|
||||||
|
|
||||||
|
### API Structure
|
||||||
|
- Backend uses Elysia.js with TypeScript
|
||||||
|
- API routes are in `src/app/api/[[...slugs]]/` directory
|
||||||
|
- Use treaty client for type-safe API calls
|
||||||
|
- Follow RESTful conventions for endpoints
|
||||||
|
- Include proper HTTP status codes and error responses
|
||||||
|
|
||||||
|
### Database Operations
|
||||||
|
- Use Prisma client from `@/lib/prisma.ts`
|
||||||
|
- Database connection includes graceful shutdown handling
|
||||||
|
- Use transactions for complex operations
|
||||||
|
- Implement proper error handling for database queries
|
||||||
|
|
||||||
|
### Component Guidelines
|
||||||
|
- Use functional components with hooks
|
||||||
|
- Implement proper prop types with TypeScript interfaces
|
||||||
|
- Use Mantine components for UI consistency
|
||||||
|
- Follow atomic design principles when possible
|
||||||
|
- Add loading states and error states for async operations
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
- Use Valtio proxies for global state
|
||||||
|
- Keep local state in components when possible
|
||||||
|
- Use SWR for server state caching
|
||||||
|
- Implement optimistic updates for better UX
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
- Primary: Mantine UI components
|
||||||
|
- Use Mantine theme system for customization
|
||||||
|
- Custom CSS should be minimal and scoped
|
||||||
|
- Follow responsive design principles
|
||||||
|
- Use semantic HTML5 elements
|
||||||
|
|
||||||
|
### Security Practices
|
||||||
|
- Validate all user inputs with Zod schemas
|
||||||
|
- Use JWT tokens for authentication
|
||||||
|
- Implement proper CORS configuration
|
||||||
|
- Never expose database credentials or API keys
|
||||||
|
- Use HTTPS in production
|
||||||
|
- Implement rate limiting for sensitive endpoints
|
||||||
|
|
||||||
|
### Performance Considerations
|
||||||
|
- Use Next.js Image optimization
|
||||||
|
- Implement proper caching strategies
|
||||||
|
- Use React.memo for expensive components
|
||||||
|
- Optimize bundle size with dynamic imports
|
||||||
|
- Use Prisma query optimization
|
||||||
|
|
||||||
|
## Domain Modules
|
||||||
|
|
||||||
|
The application is organized into several domain modules:
|
||||||
|
|
||||||
|
1. **PPID (Public Information Disclosure)**: Profile, structure, information requests, legal basis
|
||||||
|
2. **Health**: Health facilities, programs, emergency response, disease information
|
||||||
|
3. **Security**: Community security, emergency contacts, crime prevention
|
||||||
|
4. **Education**: Schools, scholarships, educational programs
|
||||||
|
5. **Economy**: Local markets, BUMDes, employment data
|
||||||
|
6. **Environment**: Environmental data, conservation, waste management
|
||||||
|
7. **Innovation**: Digital services, innovation programs
|
||||||
|
8. **Culture**: Village traditions, music, cultural preservation
|
||||||
|
|
||||||
|
Each module has its own section in both the admin panel and public-facing areas.
|
||||||
|
|
||||||
|
## File Storage Integration
|
||||||
|
|
||||||
|
The application integrates with Seafile for file storage, with specific handling for:
|
||||||
|
- Images and documents
|
||||||
|
- Public sharing capabilities
|
||||||
|
- CDN URL generation
|
||||||
|
- Batch processing of assets
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Currently no formal test framework is configured. When adding tests:
|
||||||
|
- Consider Jest or Vitest for unit testing
|
||||||
|
- Use Playwright for E2E testing
|
||||||
|
- Update this section with specific test commands
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
The application includes deployment scripts in the `NOTE.md` file that outline:
|
||||||
|
- Automated deployment with GitHub API integration
|
||||||
|
- Environment-specific configurations
|
||||||
|
- PM2 process management
|
||||||
|
- Release management with versioning
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
Common issues and solutions:
|
||||||
|
- **API endpoints returning 404**: Check that environment variables are properly configured
|
||||||
|
- **Database connection errors**: Verify DATABASE_URL in environment variables
|
||||||
|
- **File upload issues**: Ensure Seafile integration is properly configured
|
||||||
|
- **Build failures**: Run `bunx prisma generate` before building
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
1. Always run type checking before committing: `bunx tsc --noEmit`
|
||||||
|
2. Run linting to catch style issues: `bun run eslint .`
|
||||||
|
3. Test database changes with `bunx prisma db push`
|
||||||
|
4. Use the integrated Swagger docs at `/api/docs` for API testing
|
||||||
|
5. Check environment variables are properly configured
|
||||||
|
6. Verify responsive design on different screen sizes
|
||||||
30
__tests__/api/fileStorage.test.ts
Normal file
30
__tests__/api/fileStorage.test.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import ApiFetch from '@/lib/api-fetch';
|
||||||
|
|
||||||
|
describe('FileStorage API', () => {
|
||||||
|
it('should fetch a list of files from /api/fileStorage/findMany', async () => {
|
||||||
|
const response = await ApiFetch.api.fileStorage.findMany.get();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
const responseBody = response.data as any;
|
||||||
|
|
||||||
|
expect(responseBody.data).toBeInstanceOf(Array);
|
||||||
|
expect(responseBody.data.length).toBe(2);
|
||||||
|
expect(responseBody.data[0].name).toBe('file1.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a file using /api/fileStorage/create', async () => {
|
||||||
|
const mockFile = new File(['hello'], 'hello.png', { type: 'image/png' });
|
||||||
|
const response = await ApiFetch.api.fileStorage.create.post({
|
||||||
|
file: mockFile,
|
||||||
|
name: 'hello.png',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const responseBody = response.data as any;
|
||||||
|
|
||||||
|
expect(responseBody.data.realName).toBe('hello.png');
|
||||||
|
expect(responseBody.data.id).toBe('3');
|
||||||
|
});
|
||||||
|
});
|
||||||
11
__tests__/e2e/homepage.spec.ts
Normal file
11
__tests__/e2e/homepage.spec.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test('homepage has correct title and content', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Wait for the redirect to /darmasaba
|
||||||
|
await page.waitForURL('/darmasaba');
|
||||||
|
|
||||||
|
// Check for the main heading
|
||||||
|
await expect(page.getByText('DARMASABA', { exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
43
__tests__/mocks/handlers.ts
Normal file
43
__tests__/mocks/handlers.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
|
||||||
|
export const handlers = [
|
||||||
|
http.get('http://localhost:3000/api/fileStorage/findMany', () => {
|
||||||
|
return HttpResponse.json({
|
||||||
|
data: [
|
||||||
|
{ id: '1', name: 'file1.jpg', url: '/uploads/file1.jpg' },
|
||||||
|
{ id: '2', name: 'file2.png', url: '/uploads/file2.png' },
|
||||||
|
],
|
||||||
|
meta: {
|
||||||
|
page: 1,
|
||||||
|
limit: 10,
|
||||||
|
total: 2,
|
||||||
|
totalPages: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
http.post('http://localhost:3000/api/fileStorage/create', async ({ request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const file = data.get('file') as File;
|
||||||
|
const name = data.get('name') as string;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return new HttpResponse(null, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return HttpResponse.json({
|
||||||
|
data: {
|
||||||
|
id: '3',
|
||||||
|
name: 'generated-nanoid',
|
||||||
|
path: `/uploads/generated-nanoid`,
|
||||||
|
link: `/uploads/generated-nanoid`,
|
||||||
|
realName: name,
|
||||||
|
mimeType: file.type,
|
||||||
|
category: "uncategorized",
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
];
|
||||||
4
__tests__/mocks/server.ts
Normal file
4
__tests__/mocks/server.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { setupServer } from 'msw/node';
|
||||||
|
import { handlers } from './handlers';
|
||||||
|
|
||||||
|
export const server = setupServer(...handlers);
|
||||||
7
__tests__/setup.ts
Normal file
7
__tests__/setup.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { server } from './mocks/server';
|
||||||
|
import { beforeAll, afterEach, afterAll } from 'vitest';
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
|
||||||
|
afterEach(() => server.resetHandlers());
|
||||||
|
afterAll(() => server.close());
|
||||||
169
darkMode.md
Normal file
169
darkMode.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# 🌙 Dark Mode Design Specification
|
||||||
|
## Admin Darmasaba – Dashboard & CMS
|
||||||
|
|
||||||
|
Dokumen ini mendefinisikan standar **Dark Mode UI** agar:
|
||||||
|
- nyaman di mata
|
||||||
|
- konsisten
|
||||||
|
- tidak flat
|
||||||
|
- tetap profesional untuk aplikasi pemerintahan
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Color Palette (Dark Mode)
|
||||||
|
|
||||||
|
### Background Layers
|
||||||
|
| Layer | Token | Warna | Fungsi |
|
||||||
|
|------|------|------|------|
|
||||||
|
| Base | `--bg-base` | `#0B1220` | Background utama aplikasi |
|
||||||
|
| App | `--bg-app` | `#0F172A` | Area kerja utama |
|
||||||
|
| Card | `--bg-card` | `#162235` | Card / container |
|
||||||
|
| Surface | `--bg-surface` | `#1E2A3D` | Table header, tab, input |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Border & Divider
|
||||||
|
| Token | Warna | Catatan |
|
||||||
|
|-----|------|--------|
|
||||||
|
| `--border-default` | `#2A3A52` | Border utama |
|
||||||
|
| `--border-soft` | `#22314A` | Divider halus |
|
||||||
|
|
||||||
|
> ❗ Hindari border terlalu tipis (`opacity < 20%`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Text Colors
|
||||||
|
| Jenis | Token | Warna |
|
||||||
|
|-----|------|------|
|
||||||
|
| Primary | `--text-primary` | `#E5E7EB` |
|
||||||
|
| Secondary | `--text-secondary` | `#9CA3AF` |
|
||||||
|
| Muted | `--text-muted` | `#6B7280` |
|
||||||
|
| Inverse | `--text-inverse` | `#020617` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Accent & Action
|
||||||
|
| Fungsi | Warna |
|
||||||
|
|------|------|
|
||||||
|
| Primary Action | `#3B82F6` |
|
||||||
|
| Hover | `#2563EB` |
|
||||||
|
| Active | `#1D4ED8` |
|
||||||
|
| Link | `#60A5FA` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Status Colors
|
||||||
|
| Status | Warna |
|
||||||
|
|------|------|
|
||||||
|
| Success | `#22C55E` |
|
||||||
|
| Warning | `#FACC15` |
|
||||||
|
| Error | `#EF4444` |
|
||||||
|
| Info | `#38BDF8` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧱 Layout Rules
|
||||||
|
|
||||||
|
### Sidebar
|
||||||
|
- Background: `--bg-app`
|
||||||
|
- Active menu:
|
||||||
|
- Background: `rgba(59,130,246,0.15)`
|
||||||
|
- Text: Primary
|
||||||
|
- Indicator: kiri (2–3px accent bar)
|
||||||
|
- Hover:
|
||||||
|
- Background: `rgba(255,255,255,0.04)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Header / Topbar
|
||||||
|
- Background: `linear-gradient(#0F172A → #0B1220)`
|
||||||
|
- Border bawah wajib (`--border-soft`)
|
||||||
|
- Icon:
|
||||||
|
- Default: muted
|
||||||
|
- Hover: primary
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Card & Section
|
||||||
|
|
||||||
|
### Card
|
||||||
|
- Background: `--bg-card`
|
||||||
|
- Border: `--border-default`
|
||||||
|
- Radius: 12–16px
|
||||||
|
- Jangan pakai shadow hitam
|
||||||
|
|
||||||
|
### Section Header
|
||||||
|
- Font weight lebih besar
|
||||||
|
- Text: primary
|
||||||
|
- Spacing jelas dari konten
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Table (Dark Mode Friendly)
|
||||||
|
|
||||||
|
### Table Header
|
||||||
|
- Background: `--bg-surface`
|
||||||
|
- Text: secondary
|
||||||
|
- Font weight: medium
|
||||||
|
|
||||||
|
### Table Row
|
||||||
|
- Default: transparent
|
||||||
|
- Hover:
|
||||||
|
- Background: `rgba(255,255,255,0.03)`
|
||||||
|
- Divider antar row wajib terlihat
|
||||||
|
|
||||||
|
### Link di Table
|
||||||
|
- Warna link **lebih terang dari text**
|
||||||
|
- Hover underline
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔘 Button Rules
|
||||||
|
|
||||||
|
### Primary Button
|
||||||
|
- Background: Primary Action
|
||||||
|
- Text: Inverse
|
||||||
|
- Hover: darker shade
|
||||||
|
|
||||||
|
### Secondary Button
|
||||||
|
- Background: transparent
|
||||||
|
- Border: `--border-default`
|
||||||
|
- Text: primary
|
||||||
|
|
||||||
|
### Icon Button
|
||||||
|
- Default: muted
|
||||||
|
- Hover: primary + bg soft
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧭 Tab Navigation
|
||||||
|
|
||||||
|
- Inactive:
|
||||||
|
- Text: muted
|
||||||
|
- Active:
|
||||||
|
- Background: `rgba(59,130,246,0.15)`
|
||||||
|
- Text: primary
|
||||||
|
- Icon ikut berubah
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌗 Dark vs Light Mode Rule
|
||||||
|
- Layout, spacing, typography **HARUS SAMA**
|
||||||
|
- Yang boleh beda:
|
||||||
|
- warna
|
||||||
|
- border intensity
|
||||||
|
- background layer
|
||||||
|
|
||||||
|
> ❌ Jangan ganti struktur UI antara dark & light
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Dark Mode Checklist
|
||||||
|
- [ ] Kontras teks terbaca
|
||||||
|
- [ ] Active state jelas
|
||||||
|
- [ ] Hover terasa hidup
|
||||||
|
- [ ] Tidak flat
|
||||||
|
- [ ] Tidak silau
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Dokumen ini adalah **single source of truth** untuk Dark Mode.
|
||||||
@@ -19,7 +19,6 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
16
package.json
16
package.json
@@ -5,7 +5,10 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start"
|
"start": "next start",
|
||||||
|
"test:api": "vitest run",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test": "bun run test:api && bun run test:e2e"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "bun run prisma/seed.ts"
|
"seed": "bun run prisma/seed.ts"
|
||||||
@@ -50,6 +53,7 @@
|
|||||||
"add": "^2.0.6",
|
"add": "^2.0.6",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"animate.css": "^4.1.1",
|
"animate.css": "^4.1.1",
|
||||||
|
"async-mutex": "^0.5.0",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"bun": "^1.2.2",
|
"bun": "^1.2.2",
|
||||||
"chart.js": "^4.4.8",
|
"chart.js": "^4.4.8",
|
||||||
@@ -58,6 +62,7 @@
|
|||||||
"colors": "^1.4.0",
|
"colors": "^1.4.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
|
"dompurify": "^3.3.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"elysia": "^1.3.5",
|
"elysia": "^1.3.5",
|
||||||
"embla-carousel": "^8.6.0",
|
"embla-carousel": "^8.6.0",
|
||||||
@@ -105,17 +110,24 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@types/cli-progress": "^3.11.6",
|
"@types/cli-progress": "^3.11.6",
|
||||||
|
"@types/dompurify": "^3.2.0",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@vitest/ui": "^4.0.18",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.1.6",
|
"eslint-config-next": "15.1.6",
|
||||||
|
"jsdom": "^28.0.0",
|
||||||
|
"msw": "^2.12.9",
|
||||||
"parcel": "^2.6.2",
|
"parcel": "^2.6.2",
|
||||||
"postcss": "^8.5.1",
|
"postcss": "^8.5.1",
|
||||||
"postcss-preset-mantine": "^1.17.0",
|
"postcss-preset-mantine": "^1.17.0",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
# Page snapshot
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- generic [active] [ref=e1]:
|
||||||
|
- generic [ref=e2]:
|
||||||
|
- generic [ref=e7]:
|
||||||
|
- button "Darmasaba Logo" [ref=e8] [cursor=pointer]:
|
||||||
|
- img "Darmasaba Logo" [ref=e10]
|
||||||
|
- button "PPID" [ref=e11] [cursor=pointer]:
|
||||||
|
- generic [ref=e13]: PPID
|
||||||
|
- button "Desa" [ref=e14] [cursor=pointer]:
|
||||||
|
- generic [ref=e16]: Desa
|
||||||
|
- button "Kesehatan" [ref=e17] [cursor=pointer]:
|
||||||
|
- generic [ref=e19]: Kesehatan
|
||||||
|
- button "Keamanan" [ref=e20] [cursor=pointer]:
|
||||||
|
- generic [ref=e22]: Keamanan
|
||||||
|
- button "Ekonomi" [ref=e23] [cursor=pointer]:
|
||||||
|
- generic [ref=e25]: Ekonomi
|
||||||
|
- button "Inovasi" [ref=e26] [cursor=pointer]:
|
||||||
|
- generic [ref=e28]: Inovasi
|
||||||
|
- button "Lingkungan" [ref=e29] [cursor=pointer]:
|
||||||
|
- generic [ref=e31]: Lingkungan
|
||||||
|
- button "Pendidikan" [ref=e32] [cursor=pointer]:
|
||||||
|
- generic [ref=e34]: Pendidikan
|
||||||
|
- button "Musik" [ref=e35] [cursor=pointer]:
|
||||||
|
- generic [ref=e37]: Musik
|
||||||
|
- button [ref=e38] [cursor=pointer]:
|
||||||
|
- img [ref=e40]
|
||||||
|
- generic [ref=e46]:
|
||||||
|
- generic [ref=e51]:
|
||||||
|
- generic [ref=e52]:
|
||||||
|
- generic [ref=e53]:
|
||||||
|
- img "Logo Darmasaba" [ref=e55]
|
||||||
|
- img "Logo Pudak" [ref=e57]
|
||||||
|
- generic [ref=e63]:
|
||||||
|
- generic [ref=e65]:
|
||||||
|
- generic [ref=e66]:
|
||||||
|
- img [ref=e67]
|
||||||
|
- paragraph [ref=e71]: Jam Operasional
|
||||||
|
- generic [ref=e72]:
|
||||||
|
- generic [ref=e74]: Buka
|
||||||
|
- paragraph [ref=e75]: 07:30 - 15:30
|
||||||
|
- generic [ref=e77]:
|
||||||
|
- generic [ref=e78]:
|
||||||
|
- img [ref=e79]
|
||||||
|
- paragraph [ref=e82]: Hari Ini
|
||||||
|
- generic [ref=e83]:
|
||||||
|
- paragraph [ref=e84]: Status Kantor
|
||||||
|
- paragraph [ref=e85]: Sedang Beroperasi
|
||||||
|
- paragraph [ref=e95]: Bagikan ide, kritik, atau saran Anda untuk mendukung pembangunan desa. Semua lebih mudah dengan fitur interaktif yang kami sediakan.
|
||||||
|
- generic [ref=e102]:
|
||||||
|
- generic [ref=e103]: Browser Anda tidak mendukung video.
|
||||||
|
- generic [ref=e106]:
|
||||||
|
- heading "Penghargaan Desa" [level=2] [ref=e107]
|
||||||
|
- paragraph [ref=e110]: Sedang memuat data penghargaan...
|
||||||
|
- button "Lihat semua penghargaan" [ref=e111] [cursor=pointer]:
|
||||||
|
- generic [ref=e112]:
|
||||||
|
- paragraph [ref=e114]: Lihat Semua Penghargaan
|
||||||
|
- img [ref=e116]
|
||||||
|
- generic [ref=e119]:
|
||||||
|
- generic [ref=e121]:
|
||||||
|
- heading "Layanan" [level=1] [ref=e122]
|
||||||
|
- paragraph [ref=e123]: Layanan adalah fitur yang membantu warga desa mengakses berbagai kebutuhan administrasi, informasi, dan bantuan secara cepat, mudah, dan transparan. Dengan fitur ini, semua layanan desa ada dalam genggaman Anda!
|
||||||
|
- link "Detail" [ref=e125] [cursor=pointer]:
|
||||||
|
- /url: /darmasaba/desa/layanan
|
||||||
|
- generic [ref=e127]: Detail
|
||||||
|
- separator [ref=e129]
|
||||||
|
- generic [ref=e130]:
|
||||||
|
- generic [ref=e131]:
|
||||||
|
- paragraph [ref=e132]: Potensi Desa
|
||||||
|
- paragraph [ref=e133]: Jelajahi berbagai potensi dan peluang yang dimiliki desa. Fitur ini membantu warga maupun pemerintah desa dalam merencanakan dan mengembangkan program berbasis kekuatan lokal.
|
||||||
|
- paragraph [ref=e136]: Sedang memuat potensi desa...
|
||||||
|
- button "Lihat Semua Potensi" [ref=e139] [cursor=pointer]:
|
||||||
|
- generic [ref=e140]:
|
||||||
|
- generic [ref=e141]: Lihat Semua Potensi
|
||||||
|
- img [ref=e143]
|
||||||
|
- separator [ref=e146]
|
||||||
|
- generic [ref=e147]:
|
||||||
|
- generic [ref=e148]:
|
||||||
|
- paragraph [ref=e150]: Desa Anti Korupsi
|
||||||
|
- paragraph [ref=e151]: Desa antikorupsi mendorong pemerintahan jujur dan transparan. Keuangan desa dikelola secara terbuka dengan melibatkan warga dalam pengawasan anggaran, sehingga digunakan tepat sasaran dan sesuai kebutuhan masyarakat.
|
||||||
|
- link "Selengkapnya" [ref=e153] [cursor=pointer]:
|
||||||
|
- /url: /darmasaba/desa-anti-korupsi/detail
|
||||||
|
- generic [ref=e155]: Selengkapnya
|
||||||
|
- paragraph [ref=e158]: Memuat Data...
|
||||||
|
- generic [ref=e166]:
|
||||||
|
- heading "SDGs Desa" [level=1] [ref=e168]
|
||||||
|
- paragraph [ref=e169]: SDGs Desa adalah upaya desa untuk menciptakan pembangunan yang maju, inklusif, dan berkelanjutan melalui 17 tujuan mulai dari pengentasan kemiskinan, pendidikan, kesehatan, hingga pelestarian lingkungan.
|
||||||
|
- generic [ref=e170]:
|
||||||
|
- generic [ref=e171]:
|
||||||
|
- img [ref=e172]
|
||||||
|
- paragraph [ref=e175]: Data SDGs Desa belum tersedia
|
||||||
|
- link "Jelajahi Semua Tujuan SDGs Desa" [ref=e177] [cursor=pointer]:
|
||||||
|
- /url: /darmasaba/sdgs-desa
|
||||||
|
- paragraph [ref=e180]: Jelajahi Semua Tujuan SDGs Desa
|
||||||
|
- generic [ref=e181]:
|
||||||
|
- generic [ref=e183]:
|
||||||
|
- heading "APBDes" [level=1] [ref=e184]
|
||||||
|
- paragraph [ref=e185]: Transparansi APBDes Darmasaba adalah langkah nyata menuju tata kelola desa yang bersih, terbuka, dan bertanggung jawab.
|
||||||
|
- link "Lihat Semua Data" [ref=e187] [cursor=pointer]:
|
||||||
|
- /url: /darmasaba/apbdes
|
||||||
|
- generic [ref=e189]: Lihat Semua Data
|
||||||
|
- generic [ref=e191]:
|
||||||
|
- paragraph [ref=e193]: Pilih Tahun APBDes
|
||||||
|
- generic [ref=e194]:
|
||||||
|
- textbox "Pilih Tahun APBDes" [ref=e195]:
|
||||||
|
- /placeholder: Pilih tahun
|
||||||
|
- generic:
|
||||||
|
- img
|
||||||
|
- paragraph [ref=e197]: Tidak ada data APBDes untuk tahun yang dipilih.
|
||||||
|
- generic [ref=e202]:
|
||||||
|
- heading "Prestasi Desa" [level=1] [ref=e203]
|
||||||
|
- paragraph [ref=e204]: Kami bangga dengan pencapaian desa hingga saat ini. Semoga prestasi ini menjadi inspirasi untuk terus berkarya dan berinovasi demi kemajuan bersama.
|
||||||
|
- link "Lihat Semua Prestasi" [ref=e205] [cursor=pointer]:
|
||||||
|
- /url: /darmasaba/prestasi-desa
|
||||||
|
- generic [ref=e207]: Lihat Semua Prestasi
|
||||||
|
- button [ref=e211] [cursor=pointer]:
|
||||||
|
- img [ref=e214]
|
||||||
|
- button [ref=e219] [cursor=pointer]:
|
||||||
|
- img [ref=e221]
|
||||||
|
- generic [ref=e225]:
|
||||||
|
- contentinfo [ref=e228]:
|
||||||
|
- generic [ref=e230]:
|
||||||
|
- generic [ref=e231]:
|
||||||
|
- heading "Komitmen Layanan Kami" [level=2] [ref=e232]
|
||||||
|
- generic [ref=e233]:
|
||||||
|
- generic [ref=e234]:
|
||||||
|
- paragraph [ref=e235]: "1. Transparansi:"
|
||||||
|
- paragraph [ref=e236]: Pengelolaan dana desa dilakukan secara terbuka agar masyarakat dapat memahami dan memantau penggunaan anggaran.
|
||||||
|
- generic [ref=e237]:
|
||||||
|
- paragraph [ref=e238]: "2. Profesionalisme:"
|
||||||
|
- paragraph [ref=e239]: Layanan desa diberikan secara cepat, adil, dan profesional demi kepuasan masyarakat.
|
||||||
|
- generic [ref=e240]:
|
||||||
|
- paragraph [ref=e241]: "3. Partisipasi:"
|
||||||
|
- paragraph [ref=e242]: Masyarakat dilibatkan aktif dalam pengambilan keputusan demi pembangunan desa yang berhasil.
|
||||||
|
- generic [ref=e243]:
|
||||||
|
- paragraph [ref=e244]: "4. Inovasi:"
|
||||||
|
- paragraph [ref=e245]: Kami terus berinovasi, termasuk melalui teknologi, agar layanan semakin mudah diakses.
|
||||||
|
- generic [ref=e246]:
|
||||||
|
- paragraph [ref=e247]: "5. Keadilan:"
|
||||||
|
- paragraph [ref=e248]: Kebijakan dan program disusun untuk memberi manfaat yang merata bagi seluruh warga.
|
||||||
|
- generic [ref=e249]:
|
||||||
|
- paragraph [ref=e250]: "6. Pemberdayaan:"
|
||||||
|
- paragraph [ref=e251]: Masyarakat didukung melalui pelatihan, pendampingan, dan pengembangan usaha lokal.
|
||||||
|
- generic [ref=e252]:
|
||||||
|
- paragraph [ref=e253]: "7. Ramah Lingkungan:"
|
||||||
|
- paragraph [ref=e254]: Seluruh kegiatan pembangunan memperhatikan keberlanjutan demi menjaga alam dan kesehatan warga.
|
||||||
|
- separator [ref=e255]
|
||||||
|
- generic [ref=e256]:
|
||||||
|
- heading "Visi Kami" [level=2] [ref=e257]
|
||||||
|
- paragraph [ref=e258]: Dengan visi ini, kami berkomitmen menjadikan desa sebagai tempat yang aman, sejahtera, dan nyaman bagi seluruh warga.
|
||||||
|
- paragraph [ref=e259]: Kami percaya kemajuan dimulai dari kerja sama antara pemerintah desa dan masyarakat, didukung tata kelola yang baik demi kepentingan bersama. Saran maupun keluhan dapat disampaikan melalui kontak di bawah ini.
|
||||||
|
- generic [ref=e260]:
|
||||||
|
- paragraph [ref=e261]: "\"Desa Kuat, Warga Sejahtera!\""
|
||||||
|
- button "Logo Desa" [ref=e262] [cursor=pointer]:
|
||||||
|
- generic [ref=e263]:
|
||||||
|
- img "Logo Desa"
|
||||||
|
- generic [ref=e265]:
|
||||||
|
- generic [ref=e267]:
|
||||||
|
- paragraph [ref=e268]: Tentang Darmasaba
|
||||||
|
- paragraph [ref=e269]: Darmasaba adalah desa budaya yang kaya akan tradisi dan nilai-nilai warisan Bali.
|
||||||
|
- generic [ref=e270]:
|
||||||
|
- link [ref=e271] [cursor=pointer]:
|
||||||
|
- /url: https://www.facebook.com/DarmasabaDesaku
|
||||||
|
- img [ref=e273]
|
||||||
|
- link [ref=e275] [cursor=pointer]:
|
||||||
|
- /url: https://www.instagram.com/ddarmasaba/
|
||||||
|
- img [ref=e277]
|
||||||
|
- link [ref=e280] [cursor=pointer]:
|
||||||
|
- /url: https://www.youtube.com/channel/UCtPw9WOQO7d2HIKzKgel4Xg
|
||||||
|
- img [ref=e282]
|
||||||
|
- link [ref=e285] [cursor=pointer]:
|
||||||
|
- /url: https://www.tiktok.com/@desa.darmasaba?is_from_webapp=1&sender_device=pc
|
||||||
|
- img [ref=e287]
|
||||||
|
- generic [ref=e290]:
|
||||||
|
- paragraph [ref=e291]: Layanan Desa
|
||||||
|
- link "Administrasi Kependudukan" [ref=e292] [cursor=pointer]:
|
||||||
|
- /url: /darmasaba/desa/layanan/
|
||||||
|
- link "Layanan Sosial" [ref=e293] [cursor=pointer]:
|
||||||
|
- /url: /darmasaba/ekonomi/program-kemiskinan
|
||||||
|
- link "Pengaduan Masyarakat" [ref=e294] [cursor=pointer]:
|
||||||
|
- /url: /darmasaba/keamanan/laporan-publik
|
||||||
|
- link "Informasi Publik" [ref=e295] [cursor=pointer]:
|
||||||
|
- /url: /darmasaba/ppid/daftar-informasi-publik-desa-darmasaba
|
||||||
|
- generic [ref=e297]:
|
||||||
|
- paragraph [ref=e298]: Tautan Penting
|
||||||
|
- link "Portal Badung" [ref=e299] [cursor=pointer]:
|
||||||
|
- /url: /darmasaba/desa/berita/semua
|
||||||
|
- link "E-Government" [ref=e300] [cursor=pointer]:
|
||||||
|
- /url: /darmasaba/inovasi/desa-digital-smart-village
|
||||||
|
- link "Transparansi" [ref=e301] [cursor=pointer]:
|
||||||
|
- /url: /darmasaba/ppid/daftar-informasi-publik-desa-darmasaba
|
||||||
|
- generic [ref=e303]:
|
||||||
|
- paragraph [ref=e304]: Berlangganan Info
|
||||||
|
- paragraph [ref=e305]: Dapatkan kabar terbaru tentang program dan kegiatan desa langsung ke email Anda.
|
||||||
|
- generic [ref=e306]:
|
||||||
|
- generic [ref=e308]:
|
||||||
|
- textbox "Masukkan email Anda" [ref=e309]
|
||||||
|
- img [ref=e311]
|
||||||
|
- button "Daftar" [ref=e314] [cursor=pointer]:
|
||||||
|
- generic [ref=e316]: Daftar
|
||||||
|
- separator [ref=e317]
|
||||||
|
- paragraph [ref=e318]: © 2025 Desa Darmasaba. Hak cipta dilindungi.
|
||||||
|
- region "Notifications Alt+T"
|
||||||
|
- button "Open Next.js Dev Tools" [ref=e324] [cursor=pointer]:
|
||||||
|
- img [ref=e325]
|
||||||
|
- alert [ref=e328]
|
||||||
|
```
|
||||||
85
playwright-report/index.html
Normal file
85
playwright-report/index.html
Normal file
File diff suppressed because one or more lines are too long
25
playwright.config.ts
Normal file
25
playwright.config.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './__tests__/e2e',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
reporter: 'html',
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:3000',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: 'bun run dev',
|
||||||
|
url: 'http://localhost:3000',
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -26,7 +26,24 @@ export async function seedBerita() {
|
|||||||
|
|
||||||
console.log("🔄 Seeding Berita...");
|
console.log("🔄 Seeding Berita...");
|
||||||
|
|
||||||
|
// Build a map of valid kategori IDs
|
||||||
|
const validKategoriIds = new Set<string>();
|
||||||
|
const kategoriList = await prisma.kategoriBerita.findMany({
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
kategoriList.forEach((k) => validKategoriIds.add(k.id));
|
||||||
|
|
||||||
|
console.log(`📋 Found ${validKategoriIds.size} valid kategori IDs in database`);
|
||||||
|
|
||||||
for (const b of beritaJson) {
|
for (const b of beritaJson) {
|
||||||
|
// Validate kategoriBeritaId exists
|
||||||
|
if (!b.kategoriBeritaId || !validKategoriIds.has(b.kategoriBeritaId)) {
|
||||||
|
console.warn(
|
||||||
|
`⚠️ Skipping berita "${b.judul}": Invalid kategoriBeritaId "${b.kategoriBeritaId}"`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let imageId: string | null = null;
|
let imageId: string | null = null;
|
||||||
|
|
||||||
if (b.imageName) {
|
if (b.imageName) {
|
||||||
@@ -44,6 +61,7 @@ export async function seedBerita() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
await prisma.berita.upsert({
|
await prisma.berita.upsert({
|
||||||
where: { id: b.id },
|
where: { id: b.id },
|
||||||
update: {
|
update: {
|
||||||
@@ -64,6 +82,11 @@ export async function seedBerita() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log(`✅ Berita seeded: ${b.judul}`);
|
console.log(`✅ Berita seeded: ${b.judul}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(
|
||||||
|
`❌ Failed to seed berita "${b.judul}": ${error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("🎉 Berita seed selesai");
|
console.log("🎉 Berita seed selesai");
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import maskotDesa from "../../../data/desa/profile/maskot_desa.json";
|
|||||||
import profilePerbekel from "../../../data/desa/profile/profil_perbekel.json";
|
import profilePerbekel from "../../../data/desa/profile/profil_perbekel.json";
|
||||||
import profileDesaImage from "../../../data/desa/profile/profileDesaImage.json";
|
import profileDesaImage from "../../../data/desa/profile/profileDesaImage.json";
|
||||||
import sejarahDesa from "../../../data/desa/profile/sejarah_desa.json";
|
import sejarahDesa from "../../../data/desa/profile/sejarah_desa.json";
|
||||||
|
import visiMisiDesa from "../../../data/desa/profile/visi_misi_desa.json";
|
||||||
|
|
||||||
export async function seedProfileDesa() {
|
export async function seedProfileDesa() {
|
||||||
// =========== SEJARAH DESA ===========
|
// =========== SEJARAH DESA ===========
|
||||||
@@ -26,6 +27,26 @@ export async function seedProfileDesa() {
|
|||||||
|
|
||||||
console.log("sejarah desa success ...");
|
console.log("sejarah desa success ...");
|
||||||
|
|
||||||
|
// =========== VISI MISI DESA ===========
|
||||||
|
for (const l of visiMisiDesa) {
|
||||||
|
await prisma.visiMisiDesa.upsert({
|
||||||
|
where: {
|
||||||
|
id: l.id,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
visi: l.visi,
|
||||||
|
misi: l.misi,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id: l.id,
|
||||||
|
visi: l.visi,
|
||||||
|
misi: l.misi,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("visi misi desa success ...");
|
||||||
|
|
||||||
// =========== MASKOT DESA ===========
|
// =========== MASKOT DESA ===========
|
||||||
for (const l of maskotDesa) {
|
for (const l of maskotDesa) {
|
||||||
await prisma.maskotDesa.upsert({
|
await prisma.maskotDesa.upsert({
|
||||||
|
|||||||
@@ -60,3 +60,12 @@ export async function seedDataPerpustakaan() {
|
|||||||
}
|
}
|
||||||
console.log("✅ Data perpustakaan seeded successfully");
|
console.log("✅ Data perpustakaan seeded successfully");
|
||||||
}
|
}
|
||||||
|
if (import.meta.main) {
|
||||||
|
seedDataPerpustakaan()
|
||||||
|
.then(() => {
|
||||||
|
console.log("seed data perpustakaan success");
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log("gagal seed data perpustakaan", JSON.stringify(err));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -138,5 +138,61 @@
|
|||||||
"deskripsi": "<p>Cerita edukatif yang mengenalkan sains kepada anak dengan bahasa sederhana</p>",
|
"deskripsi": "<p>Cerita edukatif yang mengenalkan sains kepada anak dengan bahasa sederhana</p>",
|
||||||
"kategoriId": "cmkqb11mc000104jibqh7bdzu",
|
"kategoriId": "cmkqb11mc000104jibqh7bdzu",
|
||||||
"imageName": "G0iELZb2DhQDCCP5OdzJR-desktop.webp"
|
"imageName": "G0iELZb2DhQDCCP5OdzJR-desktop.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cml7fq776000104jscnj58sgm",
|
||||||
|
"judul": "Pedagogy of the Oppressed",
|
||||||
|
"deskripsi": "<p>Klasik pemikiran pendidikan kritis; menggali hubungan guru-murid dan peran pendidikan dalam pembebasan sosial</p>",
|
||||||
|
"kategoriId": "cmkqb11mc000104jibq97bdzu",
|
||||||
|
"imageName": "pendidikan-1.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cml7fqurm000204js5p60hkym",
|
||||||
|
"judul": "The Courage to Teach",
|
||||||
|
"deskripsi": "<p>Tentang refleksi diri seorang pendidik; cocok untuk pengajar yang ingin lebih dari sekedar “metode mengajar”</p>",
|
||||||
|
"kategoriId": "cmkqb11mc000104jibq97bdzu",
|
||||||
|
"imageName": "pendidikan-2.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cml7fqurm000204js5p60hkzn",
|
||||||
|
"judul": "A Brief History of Time",
|
||||||
|
"deskripsi": "<p>Penjelasan kosmologi yang terkenal dunia; sains kompleks dibahas dengan bahasa yang bisa dinikmati pembaca umum</p>",
|
||||||
|
"kategoriId": "cmkqb11mc000104jibqa7bdzu",
|
||||||
|
"imageName": "ilmiah-1.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cml7fqurm000204js5p60hkao",
|
||||||
|
"judul": "The Selfish Gene",
|
||||||
|
"deskripsi": "<p>Membawa perspektif baru tentang evolusi melalui “gen” sebagai unit seleksi</p>",
|
||||||
|
"kategoriId": "cmkqb11mc000104jibqa7bdzu",
|
||||||
|
"imageName": "ilmiah-2.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cml7fx09c000304jshams3xbg",
|
||||||
|
"judul": "A Little Life",
|
||||||
|
"deskripsi": "<p>Novel yang menggambarkan hidup seorang remaja yang mengalami kehidupan yang sangat sulit</p>",
|
||||||
|
"kategoriId": "cmkqb11mc000104jibqb7bdzu",
|
||||||
|
"imageName": "drama-1.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cml7fx09c000304jshams3xch",
|
||||||
|
"judul": "Death of a Salesman",
|
||||||
|
"deskripsi": "<p>Drama teater klasik Amerika tentang harapan, keluarga, dan realitas hidup.</p>",
|
||||||
|
"kategoriId": "cmkqb11mc000104jibqb7bdzu",
|
||||||
|
"imageName": "drama-2.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cml7fx09c000304jshams3xdi",
|
||||||
|
"judul": "How Not to Die",
|
||||||
|
"deskripsi": "<p>Panduan berbasis penelitian tentang pola makan untuk mencegah dan menangani penyakit.</p>",
|
||||||
|
"kategoriId": "cmkqb11mc000104jibqg7bdzu",
|
||||||
|
"imageName": "kesehatan-1.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cml7fx09c000304jshams3xej",
|
||||||
|
"judul": "The Body Keeps the Score",
|
||||||
|
"deskripsi": "<p>Fokus pada trauma, otak & tubuh; penting untuk memahami kesehatan mental secara mendalam.</p>",
|
||||||
|
"kategoriId": "cmkqb11mc000104jibqg7bdzu",
|
||||||
|
"imageName": "kesehatan-2.webp"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
246
prisma/lib/create_file_share_folder.ts
Normal file
246
prisma/lib/create_file_share_folder.ts
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
// import { getValidAuthToken } from "../../src/lib/seafile-auth-service";
|
||||||
|
|
||||||
|
// type CdnItem = {
|
||||||
|
// name: string;
|
||||||
|
// path: string;
|
||||||
|
// cdnUrl: string;
|
||||||
|
// };
|
||||||
|
|
||||||
|
// type DirItem = {
|
||||||
|
// type: "file" | "dir";
|
||||||
|
// name: string;
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const BASE_URL = process.env.SEAFILE_BASE_URL!;
|
||||||
|
// const REPO_ID = process.env.SEAFILE_REPO_ID!;
|
||||||
|
|
||||||
|
// // folder yang dishare (RELATIVE, tanpa slash depan)
|
||||||
|
// const DIR_TARGET = "asset-web";
|
||||||
|
|
||||||
|
// // 🔑 TOKEN DIRECTORY SHARE (/d/{token})
|
||||||
|
// const PUBLIC_SHARE_TOKEN = process.env.SEAFILE_PUBLIC_SHARE_TOKEN!;
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * Ambil list file dari repo (butuh token sekali)
|
||||||
|
// */
|
||||||
|
// async function getDirItems(): Promise<DirItem[]> {
|
||||||
|
// const token = await getValidAuthToken();
|
||||||
|
|
||||||
|
// // Validasi bahwa semua variabel lingkungan telah diatur
|
||||||
|
// if (!BASE_URL) {
|
||||||
|
// throw new Error('SEAFILE_BASE_URL environment variable is not set');
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (!REPO_ID) {
|
||||||
|
// throw new Error('SEAFILE_REPO_ID environment variable is not set');
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Bangun URL dan pastikan valid
|
||||||
|
// const url = `${BASE_URL}/api2/repos/${REPO_ID}/dir/?p=/${DIR_TARGET}`;
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// new URL(url); // Ini akan melempar error jika URL tidak valid
|
||||||
|
// } catch (error) {
|
||||||
|
// throw new Error(`Invalid URL constructed: ${url}. Error: ${error}`);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const res = await fetch(url, {
|
||||||
|
// headers: {
|
||||||
|
// Authorization: `Token ${token}`,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (!res.ok) {
|
||||||
|
// const text = await res.text();
|
||||||
|
// throw new Error(`Failed get dir items: ${text}`);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return res.json();
|
||||||
|
// }
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * Build PUBLIC CDN URL
|
||||||
|
// */
|
||||||
|
// function buildPublicCdnUrl(fileName: string) {
|
||||||
|
// return `${BASE_URL}/d/${PUBLIC_SHARE_TOKEN}/files/?p=${encodeURIComponent(
|
||||||
|
// fileName,
|
||||||
|
// )}&raw=1`;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * Ambil semua PUBLIC CDN URL
|
||||||
|
// */
|
||||||
|
// export async function getAllPublicCdnUrls(): Promise<CdnItem[]> {
|
||||||
|
// const items = await getDirItems();
|
||||||
|
|
||||||
|
// return items
|
||||||
|
// .filter((item) => item.type === "file")
|
||||||
|
// .map((file) => {
|
||||||
|
// const path = `${DIR_TARGET}/${file.name}`;
|
||||||
|
// return {
|
||||||
|
// name: file.name,
|
||||||
|
// path,
|
||||||
|
// cdnUrl: buildPublicCdnUrl(file.name),
|
||||||
|
// };
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * Run langsung (optional)
|
||||||
|
// */
|
||||||
|
// if (import.meta.main) {
|
||||||
|
// const data = await getAllPublicCdnUrls();
|
||||||
|
// console.log(data);
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// import { getValidAuthToken } from "../../src/lib/seafile-auth-service";
|
||||||
|
|
||||||
|
// type CdnItem = {
|
||||||
|
// name: string;
|
||||||
|
// path: string;
|
||||||
|
// cdnUrl: string;
|
||||||
|
// };
|
||||||
|
|
||||||
|
// type DirItem = {
|
||||||
|
// type: "file" | "dir";
|
||||||
|
// name: string;
|
||||||
|
// };
|
||||||
|
|
||||||
|
// // ✅ PAKAI ENV YANG BENAR
|
||||||
|
// const BASE_URL = process.env.SEAFILE_URL!;
|
||||||
|
// const REPO_ID = process.env.SEAFILE_REPO_ID!;
|
||||||
|
// const PUBLIC_SHARE_TOKEN = process.env.SEAFILE_PUBLIC_SHARE_TOKEN!;
|
||||||
|
|
||||||
|
// // folder yang dishare (RELATIVE, TANPA slash depan)
|
||||||
|
// const DIR_TARGET = "asset-web";
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * Ambil list file dari repo (token dipakai SEKALI)
|
||||||
|
// */
|
||||||
|
// async function getDirItems(): Promise<DirItem[]> {
|
||||||
|
// if (!BASE_URL || !REPO_ID) {
|
||||||
|
// throw new Error("SEAFILE env not configured correctly");
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const token = await getValidAuthToken();
|
||||||
|
|
||||||
|
// const url = `${BASE_URL}/api2/repos/${REPO_ID}/dir/?p=/${DIR_TARGET}`;
|
||||||
|
|
||||||
|
// const res = await fetch(url, {
|
||||||
|
// headers: {
|
||||||
|
// Authorization: `Token ${token}`,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (!res.ok) {
|
||||||
|
// const text = await res.text();
|
||||||
|
// throw new Error(`Failed get dir items: ${text}`);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return res.json();
|
||||||
|
// }
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * Build PUBLIC CDN URL (DIRECTORY SHARE)
|
||||||
|
// */
|
||||||
|
// function buildPublicCdnUrl(fileName: string) {
|
||||||
|
// const fullPath = `/${DIR_TARGET}/${fileName}`;
|
||||||
|
// return `${BASE_URL}/d/${PUBLIC_SHARE_TOKEN}/files/?p=${encodeURIComponent(
|
||||||
|
// fullPath,
|
||||||
|
// )}&raw=1`;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * Ambil semua PUBLIC CDN URL
|
||||||
|
// */
|
||||||
|
// export async function getAllPublicCdnUrls(): Promise<CdnItem[]> {
|
||||||
|
// const items = await getDirItems();
|
||||||
|
|
||||||
|
// return items
|
||||||
|
// .filter((item) => item.type === "file")
|
||||||
|
// .map((file) => ({
|
||||||
|
// name: file.name,
|
||||||
|
// path: `${DIR_TARGET}/${file.name}`,
|
||||||
|
// cdnUrl: buildPublicCdnUrl(file.name),
|
||||||
|
// }));
|
||||||
|
// }
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * Run langsung
|
||||||
|
// */
|
||||||
|
// if (import.meta.main) {
|
||||||
|
// const data = await getAllPublicCdnUrls();
|
||||||
|
// console.log(data);
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
import { getValidAuthToken } from "../../src/lib/seafile-auth-service";
|
||||||
|
type CdnItem = {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
cdnUrl: string;
|
||||||
|
};
|
||||||
|
type DirItem = {
|
||||||
|
type: "file" | "dir";
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
const BASE_URL = "https://cld-dkr-makuro-seafile.wibudev.com";
|
||||||
|
const REPO_ID = process.env.SEAFILE_REPO_ID!;
|
||||||
|
// folder yang dishare (RELATIVE, tanpa slash depan)
|
||||||
|
const DIR_TARGET = "asset-web";
|
||||||
|
// 🔑 TOKEN DIRECTORY SHARE (/d/{token})
|
||||||
|
const PUBLIC_SHARE_TOKEN = "3a9a9ecb5e244f4da8ae";
|
||||||
|
/**
|
||||||
|
* Ambil list file dari repo (butuh token sekali)
|
||||||
|
*/
|
||||||
|
async function getDirItems(): Promise<DirItem[]> {
|
||||||
|
const token = await getValidAuthToken();
|
||||||
|
const res = await fetch(
|
||||||
|
`${BASE_URL}/api2/repos/${REPO_ID}/dir/?p=/${DIR_TARGET}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Token ${token}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Failed get dir items: ${text}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Build PUBLIC CDN URL
|
||||||
|
*/
|
||||||
|
function buildPublicCdnUrl(fileName: string) {
|
||||||
|
return `${BASE_URL}/d/${PUBLIC_SHARE_TOKEN}/files/?p=${encodeURIComponent(
|
||||||
|
fileName,
|
||||||
|
)}&raw=1`;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Ambil semua PUBLIC CDN URL
|
||||||
|
*/
|
||||||
|
export async function getAllPublicCdnUrls(): Promise<CdnItem[]> {
|
||||||
|
const items = await getDirItems();
|
||||||
|
return items
|
||||||
|
.filter((item) => item.type === "file")
|
||||||
|
.map((file) => {
|
||||||
|
// const path = `${DIR_TARGET}/${file.name}`;
|
||||||
|
const path = `/${file.name}`;
|
||||||
|
return {
|
||||||
|
name: file.name,
|
||||||
|
path,
|
||||||
|
cdnUrl: buildPublicCdnUrl(file.name),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Run langsung (optional)
|
||||||
|
*/
|
||||||
|
if (import.meta.main) {
|
||||||
|
const data = await getAllPublicCdnUrls();
|
||||||
|
console.log(data);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getValidAuthToken } from "../../src/lib/seafile-auth-service";
|
||||||
|
|
||||||
type DirItem = {
|
type DirItem = {
|
||||||
type: "file" | "dir";
|
type: "file" | "dir";
|
||||||
name: string;
|
name: string;
|
||||||
@@ -5,7 +7,6 @@ type DirItem = {
|
|||||||
size?: number;
|
size?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TOKEN = process.env.SEAFILE_TOKEN!;
|
|
||||||
const REPO_ID = process.env.SEAFILE_REPO_ID!;
|
const REPO_ID = process.env.SEAFILE_REPO_ID!;
|
||||||
|
|
||||||
// ⛔ PENTING: RELATIVE PATH (tanpa slash depan)
|
// ⛔ PENTING: RELATIVE PATH (tanpa slash depan)
|
||||||
@@ -13,11 +14,12 @@ const DIR_TARGET = "asset-web";
|
|||||||
|
|
||||||
const BASE_URL = process.env.SEAFILE_URL;
|
const BASE_URL = process.env.SEAFILE_URL;
|
||||||
|
|
||||||
const headers = {
|
|
||||||
Authorization: `Token ${TOKEN}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
async function getDirItems(): Promise<DirItem[]> {
|
async function getDirItems(): Promise<DirItem[]> {
|
||||||
|
const token = await getValidAuthToken();
|
||||||
|
const headers = {
|
||||||
|
Authorization: `Token ${token}`,
|
||||||
|
};
|
||||||
|
|
||||||
const res = await fetch(`${BASE_URL}/repos/${REPO_ID}/dir/?p=${DIR_TARGET}`, {
|
const res = await fetch(`${BASE_URL}/repos/${REPO_ID}/dir/?p=${DIR_TARGET}`, {
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
@@ -30,6 +32,11 @@ async function getDirItems(): Promise<DirItem[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getDownloadUrl(filePath: string): Promise<string> {
|
async function getDownloadUrl(filePath: string): Promise<string> {
|
||||||
|
const token = await getValidAuthToken();
|
||||||
|
const headers = {
|
||||||
|
Authorization: `Token ${token}`,
|
||||||
|
};
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${BASE_URL}/repos/${REPO_ID}/file/?p=${encodeURIComponent(filePath)}&reuse=1`,
|
`${BASE_URL}/repos/${REPO_ID}/file/?p=${encodeURIComponent(filePath)}&reuse=1`,
|
||||||
{ headers },
|
{ headers },
|
||||||
|
|||||||
71
prisma/lib/get_shared_images.ts
Normal file
71
prisma/lib/get_shared_images.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
//ini code awal cari image by folder di seafile
|
||||||
|
|
||||||
|
|
||||||
|
type CdnItem = {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
cdnUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BASE_URL = "https://cld-dkr-makuro-seafile.wibudev.com";
|
||||||
|
const SHARE_ID = "3325e9db2c504ebf9584";
|
||||||
|
|
||||||
|
// https://cld-dkr-makuro-seafile.wibudev.com/d/3a9a9ecb5e244f4da8ae/
|
||||||
|
// https://cld-dkr-makuro-seafile.wibudev.com/d/3a9a9ecb5e244f4da8ae/files/?p=-M_tICRVz6ZxOfvkuHQgU-mobile.webp&raw=1
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build CDN URL langsung (tanpa API, tanpa token)
|
||||||
|
*/
|
||||||
|
export function buildCdnUrl(filePath: string) {
|
||||||
|
// filePath contoh: "banner/home.jpg"
|
||||||
|
return `${BASE_URL}/f/${SHARE_ID}/${filePath}?raw=1`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ambil daftar file dari PUBLIC SHARE (optional)
|
||||||
|
* Tidak pakai token
|
||||||
|
*/
|
||||||
|
async function getPublicDirItems(path = "/"): Promise<any[]> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${BASE_URL}/api/v2.1/share-links/${SHARE_ID}/dir/?p=${encodeURIComponent(
|
||||||
|
path,
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Failed get public dir items: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ambil semua CDN URL dari folder public share
|
||||||
|
*/
|
||||||
|
export async function getAllCdnUrls(
|
||||||
|
dirPath = "/",
|
||||||
|
): Promise<CdnItem[]> {
|
||||||
|
const items = await getPublicDirItems(dirPath);
|
||||||
|
|
||||||
|
return items
|
||||||
|
.filter((item: any) => item.type === "file")
|
||||||
|
.map((file: any) => {
|
||||||
|
const filePath =
|
||||||
|
dirPath === "/"
|
||||||
|
? file.name
|
||||||
|
: `${dirPath.replace(/\/$/, "")}/${file.name}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: file.name,
|
||||||
|
path: filePath,
|
||||||
|
cdnUrl: buildCdnUrl(filePath),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if(import.meta.main) {
|
||||||
|
const allCdnUrls = await getAllCdnUrls();
|
||||||
|
console.log(allCdnUrls);
|
||||||
|
}
|
||||||
33
prisma/lib/get_sharef.ts
Normal file
33
prisma/lib/get_sharef.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
const BASE_URL = "https://cld-dkr-makuro-seafile.wibudev.com";
|
||||||
|
const ADMIN_TOKEN = process.env.SEAFILE_TOKEN!;
|
||||||
|
const REPO_ID = process.env.SEAFILE_REPO_ID!;
|
||||||
|
|
||||||
|
export async function createFileShareForFolder() {
|
||||||
|
const res = await fetch(`${BASE_URL}/api/v2.1/share-links/`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Token ${ADMIN_TOKEN}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
repo_id: REPO_ID,
|
||||||
|
path: "/asset-web", // FOLDER
|
||||||
|
permission: "r",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
console.log("FILE SHARE LINK:", data);
|
||||||
|
|
||||||
|
// data.link -> https://domain/f/XXXX/
|
||||||
|
// data.token / data.id (tergantung versi)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
await createFileShareForFolder();
|
||||||
|
}
|
||||||
170
prisma/migrations/20260225082505_deploy/migration.sql
Normal file
170
prisma/migrations/20260225082505_deploy/migration.sql
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to alter the column `nama` on the `KategoriPotensi` table. The data in that column could be lost. The data in that column will be cast from `Text` to `VarChar(100)`.
|
||||||
|
- You are about to alter the column `name` on the `PotensiDesa` table. The data in that column could be lost. The data in that column will be cast from `Text` to `VarChar(255)`.
|
||||||
|
- You are about to alter the column `kategoriId` on the `PotensiDesa` table. The data in that column could be lost. The data in that column will be cast from `Text` to `VarChar(36)`.
|
||||||
|
- A unique constraint covering the columns `[nama]` on the table `KategoriPotensi` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- A unique constraint covering the columns `[name]` on the table `PotensiDesa` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- Made the column `kategoriId` on table `PotensiDesa` required. This step will fail if there are existing NULL values in that column.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "DataPerpustakaan" DROP CONSTRAINT "DataPerpustakaan_imageId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "DesaDigital" DROP CONSTRAINT "DesaDigital_imageId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "InfoTekno" DROP CONSTRAINT "InfoTekno_imageId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "KegiatanDesa" DROP CONSTRAINT "KegiatanDesa_imageId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "PengaduanMasyarakat" DROP CONSTRAINT "PengaduanMasyarakat_imageId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "PotensiDesa" DROP CONSTRAINT "PotensiDesa_kategoriId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "ProfileDesaImage" DROP CONSTRAINT "ProfileDesaImage_imageId_fkey";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "CaraMemperolehInformasi" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "CaraMemperolehSalinanInformasi" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "DaftarInformasiPublik" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "DasarHukumPPID" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "DataPerpustakaan" ALTER COLUMN "imageId" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "DesaDigital" ALTER COLUMN "imageId" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "FormulirPermohonanKeberatan" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "InfoTekno" ALTER COLUMN "imageId" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "JenisInformasiDiminta" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "JenisKelaminResponden" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "KategoriPotensi" ALTER COLUMN "nama" SET DATA TYPE VARCHAR(100),
|
||||||
|
ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "KategoriPrestasiDesa" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "KegiatanDesa" ALTER COLUMN "imageId" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "LambangDesa" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "MaskotDesa" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PegawaiPPID" ADD COLUMN "deletedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PengaduanMasyarakat" ALTER COLUMN "imageId" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PermohonanInformasiPublik" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PilihanRatingResponden" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PosisiOrganisasiPPID" ADD COLUMN "deletedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PotensiDesa" ALTER COLUMN "name" SET DATA TYPE VARCHAR(255),
|
||||||
|
ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT,
|
||||||
|
ALTER COLUMN "kategoriId" SET NOT NULL,
|
||||||
|
ALTER COLUMN "kategoriId" SET DATA TYPE VARCHAR(36);
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PrestasiDesa" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ProfileDesaImage" ALTER COLUMN "imageId" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ProfilePPID" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Responden" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "SejarahDesa" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "UmurResponden" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "VisiMisiDesa" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "VisiMisiPPID" ALTER COLUMN "deletedAt" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "deletedAt" DROP DEFAULT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "KategoriPotensi_nama_key" ON "KategoriPotensi"("nama");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PotensiDesa_name_key" ON "PotensiDesa"("name");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ProfileDesaImage" ADD CONSTRAINT "ProfileDesaImage_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PotensiDesa" ADD CONSTRAINT "PotensiDesa_kategoriId_fkey" FOREIGN KEY ("kategoriId") REFERENCES "KategoriPotensi"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DesaDigital" ADD CONSTRAINT "DesaDigital_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "InfoTekno" ADD CONSTRAINT "InfoTekno_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PengaduanMasyarakat" ADD CONSTRAINT "PengaduanMasyarakat_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "KegiatanDesa" ADD CONSTRAINT "KegiatanDesa_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DataPerpustakaan" ADD CONSTRAINT "DataPerpustakaan_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "FileStorage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -60,8 +60,9 @@ model FileStorage {
|
|||||||
deletedAt DateTime?
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
link String
|
link String
|
||||||
category String // "image" / "document" / "other"
|
category String // "image" / "document" / "audio" / "other"
|
||||||
Berita Berita[]
|
Berita Berita[] @relation("BeritaFeaturedImage")
|
||||||
|
BeritaImages Berita[] @relation("BeritaImages")
|
||||||
PotensiDesa PotensiDesa[]
|
PotensiDesa PotensiDesa[]
|
||||||
Posyandu Posyandu[]
|
Posyandu Posyandu[]
|
||||||
StrukturPPID StrukturPPID[]
|
StrukturPPID StrukturPPID[]
|
||||||
@@ -102,6 +103,9 @@ model FileStorage {
|
|||||||
|
|
||||||
ArtikelKesehatan ArtikelKesehatan[]
|
ArtikelKesehatan ArtikelKesehatan[]
|
||||||
StrukturBumDes StrukturBumDes[]
|
StrukturBumDes StrukturBumDes[]
|
||||||
|
|
||||||
|
MusikDesaAudio MusikDesa[] @relation("MusikAudioFile")
|
||||||
|
MusikDesaCover MusikDesa[] @relation("MusikCoverImage")
|
||||||
}
|
}
|
||||||
|
|
||||||
//========================================= MENU LANDING PAGE ========================================= //
|
//========================================= MENU LANDING PAGE ========================================= //
|
||||||
@@ -205,16 +209,22 @@ model APBDesItem {
|
|||||||
kode String // contoh: "4", "4.1", "4.1.2"
|
kode String // contoh: "4", "4.1", "4.1.2"
|
||||||
uraian String // nama item, contoh: "Pendapatan Asli Desa", "Hasil Usaha"
|
uraian String // nama item, contoh: "Pendapatan Asli Desa", "Hasil Usaha"
|
||||||
anggaran Float // dalam satuan Rupiah (bisa DECIMAL di DB, tapi Float umum di TS/JS)
|
anggaran Float // dalam satuan Rupiah (bisa DECIMAL di DB, tapi Float umum di TS/JS)
|
||||||
realisasi Float
|
tipe String? // "pendapatan" | "belanja" | "pembiayaan" | null
|
||||||
selisih Float // realisasi - anggaran
|
|
||||||
persentase Float
|
|
||||||
tipe String? // (realisasi / anggaran) * 100
|
|
||||||
level Int // 1 = kelompok utama, 2 = sub-kelompok, 3 = detail
|
level Int // 1 = kelompok utama, 2 = sub-kelompok, 3 = detail
|
||||||
parentId String? // untuk relasi hierarki
|
parentId String? // untuk relasi hierarki
|
||||||
parent APBDesItem? @relation("APBDesItemParent", fields: [parentId], references: [id])
|
parent APBDesItem? @relation("APBDesItemParent", fields: [parentId], references: [id])
|
||||||
children APBDesItem[] @relation("APBDesItemParent")
|
children APBDesItem[] @relation("APBDesItemParent")
|
||||||
apbdesId String
|
apbdesId String
|
||||||
apbdes APBDes @relation(fields: [apbdesId], references: [id])
|
apbdes APBDes @relation(fields: [apbdesId], references: [id])
|
||||||
|
|
||||||
|
// Field kalkulasi (auto-calculated dari realisasi items)
|
||||||
|
totalRealisasi Float @default(0) // Sum dari semua realisasi
|
||||||
|
selisih Float @default(0) // totalRealisasi - anggaran
|
||||||
|
persentase Float @default(0) // (totalRealisasi / anggaran) * 100
|
||||||
|
|
||||||
|
// Relasi ke realisasi items
|
||||||
|
realisasiItems RealisasiItem[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime?
|
deletedAt DateTime?
|
||||||
@@ -225,6 +235,26 @@ model APBDesItem {
|
|||||||
@@index([apbdesId])
|
@@index([apbdesId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Model baru untuk multiple realisasi per item
|
||||||
|
model RealisasiItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
apbdesItemId String
|
||||||
|
apbdesItem APBDesItem @relation(fields: [apbdesItemId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
jumlah Float // Jumlah realisasi dalam Rupiah
|
||||||
|
tanggal DateTime @db.Date // Tanggal realisasi
|
||||||
|
keterangan String? @db.Text // Keterangan tambahan (opsional)
|
||||||
|
buktiFileId String? // FileStorage ID untuk bukti/foto (opsional)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
|
@@index([apbdesItemId])
|
||||||
|
@@index([tanggal])
|
||||||
|
}
|
||||||
|
|
||||||
//========================================= PRESTASI DESA ========================================= //
|
//========================================= PRESTASI DESA ========================================= //
|
||||||
model PrestasiDesa {
|
model PrestasiDesa {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
@@ -236,7 +266,7 @@ model PrestasiDesa {
|
|||||||
imageId String?
|
imageId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +275,7 @@ model KategoriPrestasiDesa {
|
|||||||
name String @unique
|
name String @unique
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
PrestasiDesa PrestasiDesa[]
|
PrestasiDesa PrestasiDesa[]
|
||||||
}
|
}
|
||||||
@@ -263,7 +293,7 @@ model Responden {
|
|||||||
kelompokUmurId String
|
kelompokUmurId String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +302,7 @@ model JenisKelaminResponden {
|
|||||||
name String @unique
|
name String @unique
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
Responden Responden[]
|
Responden Responden[]
|
||||||
}
|
}
|
||||||
@@ -282,7 +312,7 @@ model PilihanRatingResponden {
|
|||||||
name String @unique
|
name String @unique
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
Responden Responden[]
|
Responden Responden[]
|
||||||
}
|
}
|
||||||
@@ -292,7 +322,7 @@ model UmurResponden {
|
|||||||
name String @unique
|
name String @unique
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
Responden Responden[]
|
Responden Responden[]
|
||||||
}
|
}
|
||||||
@@ -326,6 +356,7 @@ model PosisiOrganisasiPPID {
|
|||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
parent PosisiOrganisasiPPID? @relation("Parent", fields: [parentId], references: [id])
|
parent PosisiOrganisasiPPID? @relation("Parent", fields: [parentId], references: [id])
|
||||||
children PosisiOrganisasiPPID[] @relation("Parent")
|
children PosisiOrganisasiPPID[] @relation("Parent")
|
||||||
StrukturOrganisasiPPID StrukturOrganisasiPPID[]
|
StrukturOrganisasiPPID StrukturOrganisasiPPID[]
|
||||||
@@ -345,6 +376,7 @@ model PegawaiPPID {
|
|||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
posisi PosisiOrganisasiPPID @relation(fields: [posisiId], references: [id])
|
posisi PosisiOrganisasiPPID @relation(fields: [posisiId], references: [id])
|
||||||
strukturOrganisasi StrukturPPID[] // Relasi balik
|
strukturOrganisasi StrukturPPID[] // Relasi balik
|
||||||
StrukturOrganisasiPPID StrukturOrganisasiPPID[]
|
StrukturOrganisasiPPID StrukturOrganisasiPPID[]
|
||||||
@@ -370,7 +402,7 @@ model VisiMisiPPID {
|
|||||||
misi String @db.Text
|
misi String @db.Text
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,7 +413,7 @@ model DasarHukumPPID {
|
|||||||
content String @db.Text
|
content String @db.Text
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,7 +430,7 @@ model ProfilePPID {
|
|||||||
imageId String?
|
imageId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,7 +442,7 @@ model DaftarInformasiPublik {
|
|||||||
tanggal DateTime @db.Date
|
tanggal DateTime @db.Date
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,7 +463,7 @@ model PermohonanInformasiPublik {
|
|||||||
caraMemperolehSalinanInformasiId String?
|
caraMemperolehSalinanInformasiId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,7 +472,7 @@ model JenisInformasiDiminta {
|
|||||||
name String @unique
|
name String @unique
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
PermohonanInformasiPublik PermohonanInformasiPublik[]
|
PermohonanInformasiPublik PermohonanInformasiPublik[]
|
||||||
}
|
}
|
||||||
@@ -450,7 +482,7 @@ model CaraMemperolehInformasi {
|
|||||||
name String @unique
|
name String @unique
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
PermohonanInformasiPublik PermohonanInformasiPublik[]
|
PermohonanInformasiPublik PermohonanInformasiPublik[]
|
||||||
}
|
}
|
||||||
@@ -460,7 +492,7 @@ model CaraMemperolehSalinanInformasi {
|
|||||||
name String @unique
|
name String @unique
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
PermohonanInformasiPublik PermohonanInformasiPublik[]
|
PermohonanInformasiPublik PermohonanInformasiPublik[]
|
||||||
}
|
}
|
||||||
@@ -474,7 +506,7 @@ model FormulirPermohonanKeberatan {
|
|||||||
alasan String
|
alasan String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,7 +563,7 @@ model SejarahDesa {
|
|||||||
deskripsi String @db.Text
|
deskripsi String @db.Text
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,7 +573,7 @@ model VisiMisiDesa {
|
|||||||
misi String @db.Text
|
misi String @db.Text
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,7 +583,7 @@ model LambangDesa {
|
|||||||
deskripsi String @db.Text
|
deskripsi String @db.Text
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,7 +594,7 @@ model MaskotDesa {
|
|||||||
images ProfileDesaImage[]
|
images ProfileDesaImage[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,15 +639,19 @@ model Berita {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
judul String
|
judul String
|
||||||
deskripsi String
|
deskripsi String
|
||||||
image FileStorage? @relation(fields: [imageId], references: [id])
|
image FileStorage? @relation("BeritaFeaturedImage", fields: [imageId], references: [id])
|
||||||
imageId String?
|
imageId String?
|
||||||
|
images FileStorage[] @relation("BeritaImages")
|
||||||
content String @db.Text
|
content String @db.Text
|
||||||
|
linkVideo String? @db.VarChar(500)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime @default(now())
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
kategoriBerita KategoriBerita? @relation(fields: [kategoriBeritaId], references: [id])
|
kategoriBerita KategoriBerita? @relation(fields: [kategoriBeritaId], references: [id])
|
||||||
kategoriBeritaId String?
|
kategoriBeritaId String?
|
||||||
|
|
||||||
|
@@index([kategoriBeritaId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model KategoriBerita {
|
model KategoriBerita {
|
||||||
@@ -631,25 +667,25 @@ model KategoriBerita {
|
|||||||
// ========================================= POTENSI DESA ========================================= //
|
// ========================================= POTENSI DESA ========================================= //
|
||||||
model PotensiDesa {
|
model PotensiDesa {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String @unique @db.VarChar(255)
|
||||||
deskripsi String
|
deskripsi String @db.Text
|
||||||
kategori KategoriPotensi? @relation(fields: [kategoriId], references: [id])
|
kategori KategoriPotensi? @relation(fields: [kategoriId], references: [id])
|
||||||
kategoriId String?
|
kategoriId String @db.VarChar(36)
|
||||||
image FileStorage? @relation(fields: [imageId], references: [id])
|
image FileStorage? @relation(fields: [imageId], references: [id])
|
||||||
imageId String?
|
imageId String?
|
||||||
content String @db.Text
|
content String @db.Text
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
model KategoriPotensi {
|
model KategoriPotensi {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
nama String
|
nama String @unique @db.VarChar(100)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime @default(now())
|
deletedAt DateTime?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
PotensiDesa PotensiDesa[]
|
PotensiDesa PotensiDesa[]
|
||||||
}
|
}
|
||||||
@@ -2261,3 +2297,25 @@ model UserMenuAccess {
|
|||||||
|
|
||||||
@@unique([userId, menuId]) // Satu user tidak bisa punya akses menu yang sama dua kali
|
@@unique([userId, menuId]) // Satu user tidak bisa punya akses menu yang sama dua kali
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================= MUSIK DESA ========================================= //
|
||||||
|
model MusikDesa {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
judul String @db.VarChar(255)
|
||||||
|
artis String @db.VarChar(255)
|
||||||
|
deskripsi String? @db.Text
|
||||||
|
durasi String @db.VarChar(20) // format: "MM:SS"
|
||||||
|
audioFile FileStorage? @relation("MusikAudioFile", fields: [audioFileId], references: [id])
|
||||||
|
audioFileId String?
|
||||||
|
coverImage FileStorage? @relation("MusikCoverImage", fields: [coverImageId], references: [id])
|
||||||
|
coverImageId String?
|
||||||
|
genre String? @db.VarChar(100)
|
||||||
|
tahunRilis Int?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
|
@@index([judul])
|
||||||
|
@@index([artis])
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ import { seedBimbinganBelajar } from "./_seeder_list/pendidikan/seed_bimbingan_b
|
|||||||
import { seedDataPendidikan } from "./_seeder_list/pendidikan/seed_data_pendidikan";
|
import { seedDataPendidikan } from "./_seeder_list/pendidikan/seed_data_pendidikan";
|
||||||
import { seedPendidikanNonFormal } from "./_seeder_list/pendidikan/seed_pendidikan_non_formal";
|
import { seedPendidikanNonFormal } from "./_seeder_list/pendidikan/seed_pendidikan_non_formal";
|
||||||
import { seedDataPerpustakaan } from "./_seeder_list/pendidikan/seed_data_perpustakaan";
|
import { seedDataPerpustakaan } from "./_seeder_list/pendidikan/seed_data_perpustakaan";
|
||||||
|
import { seedProfilPpd } from "./_seeder_list/ppid/profil-ppid/seed_profil_ppd";
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
// Always run seedAssets to handle new images without duplication
|
// Always run seedAssets to handle new images without duplication
|
||||||
@@ -179,6 +180,9 @@ import { seedDataPerpustakaan } from "./_seeder_list/pendidikan/seed_data_perpus
|
|||||||
|
|
||||||
// // =========== MENU PPID ===========
|
// // =========== MENU PPID ===========
|
||||||
|
|
||||||
|
// // =========== SUBMENU PROFIL PPID ===========
|
||||||
|
await seedProfilPpd();
|
||||||
|
|
||||||
// // =========== SUBMENU STRUKTUR PPID ===========
|
// // =========== SUBMENU STRUKTUR PPID ===========
|
||||||
await seedPegawaiPpid();
|
await seedPegawaiPpid();
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
import { getAllDownloadUrls } from "./lib/get_images";
|
import { getAllPublicCdnUrls } from "./lib/create_file_share_folder";
|
||||||
|
|
||||||
export default async function seedAssets() {
|
export default async function seedAssets() {
|
||||||
const images = await getAllDownloadUrls();
|
const images = await getAllPublicCdnUrls();
|
||||||
|
|
||||||
for (const img of images) {
|
for (const img of images) {
|
||||||
try {
|
try {
|
||||||
@@ -20,7 +20,7 @@ export default async function seedAssets() {
|
|||||||
name: img.name,
|
name: img.name,
|
||||||
category: "image",
|
category: "image",
|
||||||
mimeType: "image/webp",
|
mimeType: "image/webp",
|
||||||
link: img.downloadUrl,
|
link: img.cdnUrl,
|
||||||
path: "images",
|
path: "images",
|
||||||
realName: img.name,
|
realName: img.name,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
@@ -38,12 +38,12 @@ export default async function seedAssets() {
|
|||||||
console.log("🎉 Image seeding completed");
|
console.log("🎉 Image seeding completed");
|
||||||
}
|
}
|
||||||
|
|
||||||
// if (import.meta.main) {
|
if (import.meta.main) {
|
||||||
// seedAssets()
|
seedAssets()
|
||||||
// .then(() => {
|
.then(() => {
|
||||||
// console.log("seed assets success");
|
console.log("seed assets success");
|
||||||
// })
|
})
|
||||||
// .catch((err) => {
|
.catch((err) => {
|
||||||
// console.log("gagal seed assets", JSON.stringify(err));
|
console.log("gagal seed assets", JSON.stringify(err));
|
||||||
// });
|
});
|
||||||
// }
|
}
|
||||||
|
|||||||
21
public/manifest.json
Normal file
21
public/manifest.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "Desa Darmasaba",
|
||||||
|
"short_name": "Darmasaba",
|
||||||
|
"description": "Website resmi Desa Darmasaba, Kabupaten Badung, Bali",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#1e40af",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/darmasaba-icon.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/darmasaba-icon.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
public/mp3-logo.png
Normal file
BIN
public/mp3-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
@@ -1,7 +1,11 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Grid, GridCol, Paper, TextInput, Title } from '@mantine/core';
|
import { Grid, GridCol, Paper, TextInput } from '@mantine/core';
|
||||||
import { IconSearch } from '@tabler/icons-react';
|
import { IconSearch } from '@tabler/icons-react';
|
||||||
import colors from '@/con/colors';
|
import { useDarkMode } from '@/state/darkModeStore';
|
||||||
|
import { themeTokens } from '@/utils/themeTokens';
|
||||||
|
import { UnifiedTitle } from '@/components/admin/UnifiedTypography';
|
||||||
|
|
||||||
type HeaderSearchProps = {
|
type HeaderSearchProps = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -18,13 +22,16 @@ const HeaderSearch = ({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
}: HeaderSearchProps) => {
|
}: HeaderSearchProps) => {
|
||||||
|
const { isDark } = useDarkMode();
|
||||||
|
const tokens = themeTokens(isDark);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid mb={10}>
|
<Grid mb={10}>
|
||||||
<GridCol span={{ base: 12, md: 9 }}>
|
<GridCol span={{ base: 12, md: 9 }}>
|
||||||
<Title order={3}>{title}</Title>
|
<UnifiedTitle order={3}>{title}</UnifiedTitle>
|
||||||
</GridCol>
|
</GridCol>
|
||||||
<GridCol span={{ base: 12, md: 3 }}>
|
<GridCol span={{ base: 12, md: 3 }}>
|
||||||
<Paper radius="lg" bg={colors['white-1']}>
|
<Paper radius="lg" bg={tokens.colors.bg.surface}>
|
||||||
<TextInput
|
<TextInput
|
||||||
radius="lg"
|
radius="lg"
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
@@ -32,6 +39,16 @@ const HeaderSearch = ({
|
|||||||
w="100%"
|
w="100%"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
style={{
|
||||||
|
input: {
|
||||||
|
backgroundColor: tokens.colors.bg.surface,
|
||||||
|
color: tokens.colors.text.primary,
|
||||||
|
borderColor: tokens.colors.border.default,
|
||||||
|
'::placeholder': {
|
||||||
|
color: tokens.colors.text.muted,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
</GridCol>
|
</GridCol>
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
import colors from '@/con/colors';
|
import { Grid, GridCol, Button } from '@mantine/core';
|
||||||
import { Grid, GridCol, Button, Text } from '@mantine/core';
|
|
||||||
import { IconCircleDashedPlus } from '@tabler/icons-react';
|
import { IconCircleDashedPlus } from '@tabler/icons-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useDarkMode } from '@/state/darkModeStore';
|
||||||
|
import { themeTokens } from '@/utils/themeTokens';
|
||||||
|
import { UnifiedText } from '@/components/admin/UnifiedTypography';
|
||||||
|
|
||||||
const JudulList = ({ title = "", href = "#" }) => {
|
const JudulList = ({ title = "", href = "#" }) => {
|
||||||
|
const { isDark } = useDarkMode();
|
||||||
|
const tokens = themeTokens(isDark);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const handleNavigate = () => {
|
const handleNavigate = () => {
|
||||||
@@ -16,10 +20,18 @@ const JudulList = ({ title = "", href = "#" }) => {
|
|||||||
return (
|
return (
|
||||||
<Grid align="center" mb={10}>
|
<Grid align="center" mb={10}>
|
||||||
<GridCol span={{ base: 12, md: 11 }}>
|
<GridCol span={{ base: 12, md: 11 }}>
|
||||||
<Text fz={"xl"} fw={"bold"}>{title}</Text>
|
<UnifiedText size="body" weight="bold" color="primary">{title}</UnifiedText>
|
||||||
</GridCol>
|
</GridCol>
|
||||||
<GridCol span={{ base: 12, md: 1 }} ta="right">
|
<GridCol span={{ base: 12, md: 1 }} ta="right">
|
||||||
<Button onClick={handleNavigate} bg={colors['blue-button']}>
|
<Button
|
||||||
|
onClick={handleNavigate}
|
||||||
|
bg={tokens.colors.primary}
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(135deg, ${tokens.colors.primary}, ${isDark ? '#60A5FA' : '#4facfe'})`,
|
||||||
|
color: tokens.colors.text.inverse,
|
||||||
|
boxShadow: isDark ? 'none' : `0 4px 15px rgba(79, 172, 254, 0.4)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<IconCircleDashedPlus size={25} />
|
<IconCircleDashedPlus size={25} />
|
||||||
</Button>
|
</Button>
|
||||||
</GridCol>
|
</GridCol>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import colors from '@/con/colors';
|
import { Grid, GridCol, Button, Paper, TextInput } from '@mantine/core';
|
||||||
import { Grid, GridCol, Button, Text, Paper, TextInput } from '@mantine/core';
|
|
||||||
import { IconCircleDashedPlus, IconSearch } from '@tabler/icons-react';
|
import { IconCircleDashedPlus, IconSearch } from '@tabler/icons-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useDarkMode } from '@/state/darkModeStore';
|
||||||
|
import { themeTokens } from '@/utils/themeTokens';
|
||||||
|
import { UnifiedText } from '@/components/admin/UnifiedTypography';
|
||||||
|
|
||||||
type JudulListTabProps = {
|
type JudulListTabProps = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -14,9 +16,6 @@ type JudulListTabProps = {
|
|||||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const JudulListTab = ({
|
const JudulListTab = ({
|
||||||
title = "",
|
title = "",
|
||||||
href = "#",
|
href = "#",
|
||||||
@@ -25,6 +24,8 @@ const JudulListTab = ({
|
|||||||
value,
|
value,
|
||||||
onChange
|
onChange
|
||||||
}: JudulListTabProps) => {
|
}: JudulListTabProps) => {
|
||||||
|
const { isDark } = useDarkMode();
|
||||||
|
const tokens = themeTokens(isDark);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const handleNavigate = () => {
|
const handleNavigate = () => {
|
||||||
@@ -34,10 +35,17 @@ const JudulListTab = ({
|
|||||||
return (
|
return (
|
||||||
<Grid mb={10}>
|
<Grid mb={10}>
|
||||||
<GridCol span={{ base: 12, md: 8 }}>
|
<GridCol span={{ base: 12, md: 8 }}>
|
||||||
<Text fz={{ base: "md", md: "xl" }} fw={"bold"}>{title}</Text>
|
<UnifiedText
|
||||||
|
size="body"
|
||||||
|
weight="bold"
|
||||||
|
color="primary"
|
||||||
|
style={{ fontSize: 'clamp(1rem, 2vw, 1.25rem)' }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</UnifiedText>
|
||||||
</GridCol>
|
</GridCol>
|
||||||
<GridCol span={{ base: 9, md: 3 }} ta="right">
|
<GridCol span={{ base: 9, md: 3 }} ta="right">
|
||||||
<Paper radius={"lg"} bg={colors['white-1']}>
|
<Paper radius={"lg"} bg={tokens.colors.bg.surface}>
|
||||||
<TextInput
|
<TextInput
|
||||||
radius="lg"
|
radius="lg"
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
@@ -45,11 +53,29 @@ const JudulListTab = ({
|
|||||||
w="100%"
|
w="100%"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
style={{
|
||||||
|
input: {
|
||||||
|
backgroundColor: tokens.colors.bg.surface,
|
||||||
|
color: tokens.colors.text.primary,
|
||||||
|
borderColor: tokens.colors.border.default,
|
||||||
|
'::placeholder': {
|
||||||
|
color: tokens.colors.text.muted,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
</GridCol>
|
</GridCol>
|
||||||
<GridCol span={{ base: 3, md: 1 }} ta="right">
|
<GridCol span={{ base: 3, md: 1 }} ta="right">
|
||||||
<Button onClick={handleNavigate} bg={colors['blue-button']}>
|
<Button
|
||||||
|
onClick={handleNavigate}
|
||||||
|
bg={tokens.colors.primary}
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(135deg, ${tokens.colors.primary}, ${isDark ? '#60A5FA' : '#4facfe'})`,
|
||||||
|
color: tokens.colors.text.inverse,
|
||||||
|
boxShadow: isDark ? 'none' : `0 4px 15px rgba(79, 172, 254, 0.4)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<IconCircleDashedPlus size={25} />
|
<IconCircleDashedPlus size={25} />
|
||||||
</Button>
|
</Button>
|
||||||
</GridCol>
|
</GridCol>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ const templateForm = z.object({
|
|||||||
content: z.string().min(3, "Content minimal 3 karakter"),
|
content: z.string().min(3, "Content minimal 3 karakter"),
|
||||||
kategoriBeritaId: z.string().nonempty(),
|
kategoriBeritaId: z.string().nonempty(),
|
||||||
imageId: z.string().nonempty(),
|
imageId: z.string().nonempty(),
|
||||||
|
imageIds: z.array(z.string()),
|
||||||
|
linkVideo: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Default value form berita (hindari uncontrolled input)
|
// 2. Default value form berita (hindari uncontrolled input)
|
||||||
@@ -21,6 +23,8 @@ const defaultForm = {
|
|||||||
imageId: "",
|
imageId: "",
|
||||||
content: "",
|
content: "",
|
||||||
kategoriBeritaId: "",
|
kategoriBeritaId: "",
|
||||||
|
imageIds: [] as string[],
|
||||||
|
linkVideo: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 4. Berita proxy
|
// 4. Berita proxy
|
||||||
@@ -62,14 +66,7 @@ const berita = proxy({
|
|||||||
// State untuk berita utama (hanya 1)
|
// State untuk berita utama (hanya 1)
|
||||||
|
|
||||||
findMany: {
|
findMany: {
|
||||||
data: null as
|
data: null as any[] | null,
|
||||||
| Prisma.BeritaGetPayload<{
|
|
||||||
include: {
|
|
||||||
image: true;
|
|
||||||
kategoriBerita: true;
|
|
||||||
};
|
|
||||||
}>[]
|
|
||||||
| null,
|
|
||||||
page: 1,
|
page: 1,
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -115,6 +112,7 @@ const berita = proxy({
|
|||||||
data: null as Prisma.BeritaGetPayload<{
|
data: null as Prisma.BeritaGetPayload<{
|
||||||
include: {
|
include: {
|
||||||
image: true;
|
image: true;
|
||||||
|
images: true;
|
||||||
kategoriBerita: true;
|
kategoriBerita: true;
|
||||||
};
|
};
|
||||||
}> | null,
|
}> | null,
|
||||||
@@ -199,6 +197,8 @@ const berita = proxy({
|
|||||||
content: data.content,
|
content: data.content,
|
||||||
kategoriBeritaId: data.kategoriBeritaId || "",
|
kategoriBeritaId: data.kategoriBeritaId || "",
|
||||||
imageId: data.imageId || "",
|
imageId: data.imageId || "",
|
||||||
|
imageIds: data.images?.map((img: any) => img.id) || [],
|
||||||
|
linkVideo: data.linkVideo || "",
|
||||||
};
|
};
|
||||||
return data; // Return the loaded data
|
return data; // Return the loaded data
|
||||||
} else {
|
} else {
|
||||||
@@ -237,6 +237,8 @@ const berita = proxy({
|
|||||||
content: this.form.content,
|
content: this.form.content,
|
||||||
kategoriBeritaId: this.form.kategoriBeritaId || null,
|
kategoriBeritaId: this.form.kategoriBeritaId || null,
|
||||||
imageId: this.form.imageId,
|
imageId: this.form.imageId,
|
||||||
|
imageIds: this.form.imageIds,
|
||||||
|
linkVideo: this.form.linkVideo,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
297
src/app/admin/(dashboard)/_state/desa/musik.ts
Normal file
297
src/app/admin/(dashboard)/_state/desa/musik.ts
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import ApiFetch from "@/lib/api-fetch";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { proxy } from "valtio";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// 1. Schema validasi dengan Zod
|
||||||
|
const templateForm = z.object({
|
||||||
|
judul: z.string().min(3, "Judul minimal 3 karakter"),
|
||||||
|
artis: z.string().min(3, "Artis minimal 3 karakter"),
|
||||||
|
deskripsi: z.string().optional(),
|
||||||
|
durasi: z.string().min(3, "Durasi minimal 3 karakter"),
|
||||||
|
audioFileId: z.string().nonempty(),
|
||||||
|
coverImageId: z.string().nonempty(),
|
||||||
|
genre: z.string().optional(),
|
||||||
|
tahunRilis: z.number().optional().or(z.literal(undefined)),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Default value form musik
|
||||||
|
const defaultForm = {
|
||||||
|
judul: "",
|
||||||
|
artis: "",
|
||||||
|
deskripsi: "",
|
||||||
|
durasi: "",
|
||||||
|
audioFileId: "",
|
||||||
|
coverImageId: "",
|
||||||
|
genre: "",
|
||||||
|
tahunRilis: undefined as number | undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Musik proxy
|
||||||
|
const musik = proxy({
|
||||||
|
create: {
|
||||||
|
form: { ...defaultForm },
|
||||||
|
loading: false,
|
||||||
|
async create() {
|
||||||
|
const cek = templateForm.safeParse(musik.create.form);
|
||||||
|
if (!cek.success) {
|
||||||
|
const err = `[${cek.error.issues
|
||||||
|
.map((v) => `${v.path.join(".")}`)
|
||||||
|
.join("\n")}] required`;
|
||||||
|
return toast.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
musik.create.loading = true;
|
||||||
|
const res = await ApiFetch.api.desa.musik["create"].post(
|
||||||
|
musik.create.form
|
||||||
|
);
|
||||||
|
if (res.status === 200) {
|
||||||
|
musik.findMany.load();
|
||||||
|
return toast.success("Musik berhasil disimpan!");
|
||||||
|
}
|
||||||
|
|
||||||
|
return toast.error("Gagal menyimpan musik");
|
||||||
|
} catch (error) {
|
||||||
|
console.log((error as Error).message);
|
||||||
|
} finally {
|
||||||
|
musik.create.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resetForm() {
|
||||||
|
musik.create.form = { ...defaultForm };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
findMany: {
|
||||||
|
data: null as
|
||||||
|
| Prisma.MusikDesaGetPayload<{
|
||||||
|
include: {
|
||||||
|
audioFile: true;
|
||||||
|
coverImage: true;
|
||||||
|
};
|
||||||
|
}>[]
|
||||||
|
| null,
|
||||||
|
page: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
loading: false,
|
||||||
|
search: "",
|
||||||
|
load: async (page = 1, limit = 10, search = "", genre = "") => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
musik.findMany.loading = true;
|
||||||
|
musik.findMany.page = page;
|
||||||
|
musik.findMany.search = search;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const query: any = { page, limit };
|
||||||
|
if (search) query.search = search;
|
||||||
|
if (genre) query.genre = genre;
|
||||||
|
|
||||||
|
const res = await ApiFetch.api.desa.musik["find-many"].get({ query });
|
||||||
|
|
||||||
|
if (res.status === 200 && res.data?.success) {
|
||||||
|
musik.findMany.data = res.data.data ?? [];
|
||||||
|
musik.findMany.totalPages = res.data.totalPages ?? 1;
|
||||||
|
} else {
|
||||||
|
musik.findMany.data = [];
|
||||||
|
musik.findMany.totalPages = 1;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Gagal fetch musik paginated:", err);
|
||||||
|
musik.findMany.data = [];
|
||||||
|
musik.findMany.totalPages = 1;
|
||||||
|
} finally {
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
const minDelay = 300;
|
||||||
|
const delay = elapsed < minDelay ? minDelay - elapsed : 0;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
musik.findMany.loading = false;
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
findUnique: {
|
||||||
|
data: null as Prisma.MusikDesaGetPayload<{
|
||||||
|
include: {
|
||||||
|
audioFile: true;
|
||||||
|
coverImage: true;
|
||||||
|
};
|
||||||
|
}> | null,
|
||||||
|
loading: false,
|
||||||
|
async load(id: string) {
|
||||||
|
try {
|
||||||
|
musik.findUnique.loading = true;
|
||||||
|
const res = await fetch(`/api/desa/musik/${id}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
musik.findUnique.data = data.data ?? null;
|
||||||
|
} else {
|
||||||
|
console.error("Failed to fetch musik:", res.statusText);
|
||||||
|
musik.findUnique.data = null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching musik:", error);
|
||||||
|
musik.findUnique.data = null;
|
||||||
|
} finally {
|
||||||
|
musik.findUnique.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
loading: false,
|
||||||
|
async byId(id: string) {
|
||||||
|
if (!id) return toast.warn("ID tidak valid");
|
||||||
|
|
||||||
|
try {
|
||||||
|
musik.delete.loading = true;
|
||||||
|
|
||||||
|
const response = await fetch(`/api/desa/musik/delete/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && result?.success) {
|
||||||
|
toast.success(result.message || "Musik berhasil dihapus");
|
||||||
|
await musik.findMany.load();
|
||||||
|
} else {
|
||||||
|
toast.error(result?.message || "Gagal menghapus musik");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Gagal delete:", error);
|
||||||
|
toast.error("Terjadi kesalahan saat menghapus musik");
|
||||||
|
} finally {
|
||||||
|
musik.delete.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
edit: {
|
||||||
|
id: "",
|
||||||
|
form: { ...defaultForm },
|
||||||
|
loading: false,
|
||||||
|
|
||||||
|
async load(id: string) {
|
||||||
|
if (!id) {
|
||||||
|
toast.warn("ID tidak valid");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/desa/musik/${id}`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result?.success) {
|
||||||
|
const data = result.data;
|
||||||
|
this.id = data.id;
|
||||||
|
this.form = {
|
||||||
|
judul: data.judul,
|
||||||
|
artis: data.artis,
|
||||||
|
deskripsi: data.deskripsi || "",
|
||||||
|
durasi: data.durasi,
|
||||||
|
audioFileId: data.audioFileId || "",
|
||||||
|
coverImageId: data.coverImageId || "",
|
||||||
|
genre: data.genre || "",
|
||||||
|
tahunRilis: data.tahunRilis || undefined,
|
||||||
|
};
|
||||||
|
return data;
|
||||||
|
} else {
|
||||||
|
throw new Error(result?.message || "Gagal memuat data");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading musik:", error);
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Gagal memuat data"
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async update() {
|
||||||
|
const cek = templateForm.safeParse(musik.edit.form);
|
||||||
|
if (!cek.success) {
|
||||||
|
const err = `[${cek.error.issues
|
||||||
|
.map((v) => `${v.path.join(".")}`)
|
||||||
|
.join("\n")}] required`;
|
||||||
|
toast.error(err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
musik.edit.loading = true;
|
||||||
|
|
||||||
|
const response = await fetch(`/api/desa/musik/${this.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
judul: this.form.judul,
|
||||||
|
artis: this.form.artis,
|
||||||
|
deskripsi: this.form.deskripsi,
|
||||||
|
durasi: this.form.durasi,
|
||||||
|
audioFileId: this.form.audioFileId,
|
||||||
|
coverImageId: this.form.coverImageId,
|
||||||
|
genre: this.form.genre,
|
||||||
|
tahunRilis: this.form.tahunRilis,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(
|
||||||
|
errorData.message || `HTTP error! status: ${response.status}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Musik berhasil diupdate");
|
||||||
|
await musik.findMany.load();
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message || "Gagal update musik");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating musik:", error);
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Terjadi kesalahan saat update musik"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
musik.edit.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
musik.edit.id = "";
|
||||||
|
musik.edit.form = { ...defaultForm };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. State global
|
||||||
|
const stateDashboardMusik = proxy({
|
||||||
|
musik,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default stateDashboardMusik;
|
||||||
@@ -5,20 +5,28 @@ import { toast } from "react-toastify";
|
|||||||
import { proxy } from "valtio";
|
import { proxy } from "valtio";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
// --- Zod Schema ---
|
// --- Zod Schema untuk APBDes Item (tanpa field kalkulasi) ---
|
||||||
const ApbdesItemSchema = z.object({
|
const ApbdesItemSchema = z.object({
|
||||||
kode: z.string().min(1, "Kode wajib diisi"),
|
kode: z.string().min(1, "Kode wajib diisi"),
|
||||||
uraian: z.string().min(1, "Uraian wajib diisi"),
|
uraian: z.string().min(1, "Uraian wajib diisi"),
|
||||||
anggaran: z.number().min(0),
|
anggaran: z.number().min(0, "Anggaran tidak boleh negatif"),
|
||||||
realisasi: z.number().min(0),
|
|
||||||
selisih: z.number(),
|
|
||||||
persentase: z.number(),
|
|
||||||
level: z.number().int().min(1).max(3),
|
level: z.number().int().min(1).max(3),
|
||||||
tipe: z.enum(['pendapatan', 'belanja', 'pembiayaan']).nullable().optional(),
|
tipe: z.enum(['pendapatan', 'belanja', 'pembiayaan']).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Zod Schema untuk Realisasi Item ---
|
||||||
|
const RealisasiItemSchema = z.object({
|
||||||
|
jumlah: z.number().min(0, "Jumlah tidak boleh negatif"),
|
||||||
|
tanggal: z.string(),
|
||||||
|
keterangan: z.string().optional(),
|
||||||
|
buktiFileId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const ApbdesFormSchema = z.object({
|
const ApbdesFormSchema = z.object({
|
||||||
tahun: z.number().int().min(2000, "Tahun tidak valid"),
|
tahun: z.number().int().min(2000, "Tahun tidak valid"),
|
||||||
|
name: z.string().optional(),
|
||||||
|
deskripsi: z.string().optional(),
|
||||||
|
jumlah: z.string().optional(),
|
||||||
imageId: z.string().min(1, "Gambar wajib diunggah"),
|
imageId: z.string().min(1, "Gambar wajib diunggah"),
|
||||||
fileId: z.string().min(1, "File wajib diunggah"),
|
fileId: z.string().min(1, "File wajib diunggah"),
|
||||||
items: z.array(ApbdesItemSchema).min(1, "Minimal ada 1 item"),
|
items: z.array(ApbdesItemSchema).min(1, "Minimal ada 1 item"),
|
||||||
@@ -27,33 +35,22 @@ const ApbdesFormSchema = z.object({
|
|||||||
// --- Default Form ---
|
// --- Default Form ---
|
||||||
const defaultApbdesForm = {
|
const defaultApbdesForm = {
|
||||||
tahun: new Date().getFullYear(),
|
tahun: new Date().getFullYear(),
|
||||||
|
name: "",
|
||||||
|
deskripsi: "",
|
||||||
|
jumlah: "",
|
||||||
imageId: "",
|
imageId: "",
|
||||||
fileId: "",
|
fileId: "",
|
||||||
items: [] as z.infer<typeof ApbdesItemSchema>[],
|
items: [] as z.infer<typeof ApbdesItemSchema>[],
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
|
// --- Helper: Normalize item (tanpa kalkulasi, backend yang hitung) ---
|
||||||
// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
|
|
||||||
function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer<typeof ApbdesItemSchema> {
|
function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer<typeof ApbdesItemSchema> {
|
||||||
const anggaran = item.anggaran ?? 0;
|
|
||||||
const realisasi = item.realisasi ?? 0;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ✅ Formula yang benar
|
|
||||||
const selisih = anggaran - realisasi; // positif = sisa anggaran, negatif = over budget
|
|
||||||
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0; // persentase realisasi terhadap anggaran
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kode: item.kode || "",
|
kode: item.kode || "",
|
||||||
uraian: item.uraian || "",
|
uraian: item.uraian || "",
|
||||||
anggaran,
|
anggaran: item.anggaran ?? 0,
|
||||||
realisasi,
|
|
||||||
selisih,
|
|
||||||
persentase,
|
|
||||||
level: item.level || 1,
|
level: item.level || 1,
|
||||||
tipe: item.tipe, // biarkan null jika memang null
|
tipe: item.tipe ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,15 +243,15 @@ const apbdes = proxy({
|
|||||||
this.id = data.id;
|
this.id = data.id;
|
||||||
this.form = {
|
this.form = {
|
||||||
tahun: data.tahun || new Date().getFullYear(),
|
tahun: data.tahun || new Date().getFullYear(),
|
||||||
|
name: data.name || "",
|
||||||
|
deskripsi: data.deskripsi || "",
|
||||||
|
jumlah: data.jumlah || "",
|
||||||
imageId: data.imageId || "",
|
imageId: data.imageId || "",
|
||||||
fileId: data.fileId || "",
|
fileId: data.fileId || "",
|
||||||
items: (data.items || []).map((item: any) => ({
|
items: (data.items || []).map((item: any) => ({
|
||||||
kode: item.kode,
|
kode: item.kode,
|
||||||
uraian: item.uraian,
|
uraian: item.uraian,
|
||||||
anggaran: item.anggaran,
|
anggaran: item.anggaran,
|
||||||
realisasi: item.realisasi,
|
|
||||||
selisih: item.selisih,
|
|
||||||
persentase: item.persentase,
|
|
||||||
level: item.level,
|
level: item.level,
|
||||||
tipe: item.tipe || 'pendapatan',
|
tipe: item.tipe || 'pendapatan',
|
||||||
})),
|
})),
|
||||||
@@ -319,6 +316,80 @@ const apbdes = proxy({
|
|||||||
this.form = { ...defaultApbdesForm };
|
this.form = { ...defaultApbdesForm };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// =========================================
|
||||||
|
// REALISASI STATE MANAGEMENT
|
||||||
|
// =========================================
|
||||||
|
realisasi: {
|
||||||
|
// Create realisasi
|
||||||
|
async create(itemId: string, data: { jumlah: number; tanggal: string; keterangan?: string; buktiFileId?: string }) {
|
||||||
|
try {
|
||||||
|
const res = await (ApiFetch.api.landingpage.apbdes as any)[itemId].realisasi.post(data);
|
||||||
|
|
||||||
|
if (res.data?.success) {
|
||||||
|
toast.success("Realisasi berhasil ditambahkan");
|
||||||
|
// Reload findUnique untuk update data
|
||||||
|
if (apbdes.findUnique.data) {
|
||||||
|
await apbdes.findUnique.load(apbdes.findUnique.data.id);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
toast.error(res.data?.message || "Gagal menambahkan realisasi");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Create realisasi error:", error);
|
||||||
|
toast.error(error?.message || "Terjadi kesalahan saat menambahkan realisasi");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update realisasi
|
||||||
|
async update(realisasiId: string, data: { jumlah?: number; tanggal?: string; keterangan?: string; buktiFileId?: string }) {
|
||||||
|
try {
|
||||||
|
const res = await (ApiFetch.api.landingpage.apbdes as any).realisasi[realisasiId].put(data);
|
||||||
|
|
||||||
|
if (res.data?.success) {
|
||||||
|
toast.success("Realisasi berhasil diperbarui");
|
||||||
|
// Reload findUnique untuk update data
|
||||||
|
if (apbdes.findUnique.data) {
|
||||||
|
await apbdes.findUnique.load(apbdes.findUnique.data.id);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
toast.error(res.data?.message || "Gagal memperbarui realisasi");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Update realisasi error:", error);
|
||||||
|
toast.error(error?.message || "Terjadi kesalahan saat memperbarui realisasi");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete realisasi
|
||||||
|
async delete(realisasiId: string) {
|
||||||
|
try {
|
||||||
|
const res = await (ApiFetch.api.landingpage.apbdes as any).realisasi[realisasiId].delete();
|
||||||
|
|
||||||
|
if (res.data?.success) {
|
||||||
|
toast.success("Realisasi berhasil dihapus");
|
||||||
|
// Reload findUnique untuk update data
|
||||||
|
if (apbdes.findUnique.data) {
|
||||||
|
await apbdes.findUnique.load(apbdes.findUnique.data.id);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
toast.error(res.data?.message || "Gagal menghapus realisasi");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Delete realisasi error:", error);
|
||||||
|
toast.error(error?.message || "Terjadi kesalahan saat menghapus realisasi");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default apbdes;
|
export default apbdes;
|
||||||
@@ -55,10 +55,15 @@ const programInovasi = proxy({
|
|||||||
programInovasi.findMany.load();
|
programInovasi.findMany.load();
|
||||||
return toast.success("Sukses menambahkan");
|
return toast.success("Sukses menambahkan");
|
||||||
}
|
}
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.log(res);
|
console.log(res);
|
||||||
|
}
|
||||||
return toast.error("failed create");
|
return toast.error("failed create");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log((error as Error).message);
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.error("Create error:", error);
|
||||||
|
}
|
||||||
|
toast.error("Gagal menambahkan data");
|
||||||
} finally {
|
} finally {
|
||||||
programInovasi.create.loading = false;
|
programInovasi.create.loading = false;
|
||||||
}
|
}
|
||||||
@@ -91,13 +96,17 @@ const programInovasi = proxy({
|
|||||||
programInovasi.findMany.total = res.data.total || 0;
|
programInovasi.findMany.total = res.data.total || 0;
|
||||||
programInovasi.findMany.totalPages = res.data.totalPages || 1;
|
programInovasi.findMany.totalPages = res.data.totalPages || 1;
|
||||||
} else {
|
} else {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.error("Failed to load pegawai:", res.data?.message);
|
console.error("Failed to load pegawai:", res.data?.message);
|
||||||
|
}
|
||||||
programInovasi.findMany.data = [];
|
programInovasi.findMany.data = [];
|
||||||
programInovasi.findMany.total = 0;
|
programInovasi.findMany.total = 0;
|
||||||
programInovasi.findMany.totalPages = 1;
|
programInovasi.findMany.totalPages = 1;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.error("Error loading pegawai:", error);
|
console.error("Error loading pegawai:", error);
|
||||||
|
}
|
||||||
programInovasi.findMany.data = [];
|
programInovasi.findMany.data = [];
|
||||||
programInovasi.findMany.total = 0;
|
programInovasi.findMany.total = 0;
|
||||||
programInovasi.findMany.totalPages = 1;
|
programInovasi.findMany.totalPages = 1;
|
||||||
@@ -112,19 +121,25 @@ const programInovasi = proxy({
|
|||||||
image: true;
|
image: true;
|
||||||
};
|
};
|
||||||
}> | null,
|
}> | null,
|
||||||
|
loading: false,
|
||||||
async load(id: string) {
|
async load(id: string) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/landingpage/programinovasi/${id}`);
|
programInovasi.findUnique.loading = true;
|
||||||
if (res.ok) {
|
const res = await (ApiFetch.api.landingpage.programinovasi as any)[id].get();
|
||||||
const data = await res.json();
|
if (res.data?.success) {
|
||||||
programInovasi.findUnique.data = data.data ?? null;
|
programInovasi.findUnique.data = res.data.data ?? null;
|
||||||
|
return res.data.data;
|
||||||
} else {
|
} else {
|
||||||
console.error("Failed to fetch program inovasi:", res.statusText);
|
toast.error(res.data?.message || "Gagal memuat data program inovasi");
|
||||||
programInovasi.findUnique.data = null;
|
programInovasi.findUnique.data = null;
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching program inovasi:", error);
|
console.error("Error fetching program inovasi:", error);
|
||||||
programInovasi.findUnique.data = null;
|
programInovasi.findUnique.data = null;
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
programInovasi.findUnique.loading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -135,27 +150,18 @@ const programInovasi = proxy({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
programInovasi.delete.loading = true;
|
programInovasi.delete.loading = true;
|
||||||
|
const res = await (ApiFetch.api.landingpage.programinovasi as any)["del"][id].delete();
|
||||||
|
|
||||||
const response = await fetch(
|
if (res.data?.success) {
|
||||||
`/api/landingpage/programinovasi/del/${id}`,
|
toast.success(res.data.message || "Program inovasi berhasil dihapus");
|
||||||
{
|
await programInovasi.findMany.load();
|
||||||
method: "DELETE",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (response.ok && result?.success) {
|
|
||||||
toast.success(result.message || "Program inovasi berhasil dihapus");
|
|
||||||
await programInovasi.findMany.load(); // refresh list
|
|
||||||
} else {
|
} else {
|
||||||
toast.error(result?.message || "Gagal menghapus program inovasi");
|
toast.error(res.data?.message || "Gagal menghapus program inovasi");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.error("Gagal delete:", error);
|
console.error("Gagal delete:", error);
|
||||||
|
}
|
||||||
toast.error("Terjadi kesalahan saat menghapus program inovasi");
|
toast.error("Terjadi kesalahan saat menghapus program inovasi");
|
||||||
} finally {
|
} finally {
|
||||||
programInovasi.delete.loading = false;
|
programInovasi.delete.loading = false;
|
||||||
@@ -174,20 +180,11 @@ const programInovasi = proxy({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/landingpage/programinovasi/${id}`, {
|
programInovasi.update.loading = true;
|
||||||
method: "GET",
|
const res = await (ApiFetch.api.landingpage.programinovasi as any)[id].get();
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
if (res.data?.success) {
|
||||||
|
const data = res.data.data;
|
||||||
if (result?.success) {
|
|
||||||
const data = result.data;
|
|
||||||
this.id = data.id;
|
this.id = data.id;
|
||||||
this.form = {
|
this.form = {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
@@ -197,13 +194,15 @@ const programInovasi = proxy({
|
|||||||
};
|
};
|
||||||
return data;
|
return data;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
toast.error(res.data?.message || "Gagal mengambil data program inovasi");
|
||||||
result?.message || "Gagal mengambil data program inovasi"
|
return null;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error((error as Error).message);
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.error("Error loading program inovasi:", error);
|
||||||
|
}
|
||||||
toast.error("Terjadi kesalahan saat mengambil data program inovasi");
|
toast.error("Terjadi kesalahan saat mengambil data program inovasi");
|
||||||
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
programInovasi.update.loading = false;
|
programInovasi.update.loading = false;
|
||||||
}
|
}
|
||||||
@@ -221,41 +220,25 @@ const programInovasi = proxy({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
programInovasi.update.loading = true;
|
programInovasi.update.loading = true;
|
||||||
|
const res = await (ApiFetch.api.landingpage.programinovasi as any)[this.id].put({
|
||||||
const response = await fetch(
|
|
||||||
`/api/landingpage/programinovasi/${this.id}`,
|
|
||||||
{
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: this.form.name,
|
name: this.form.name,
|
||||||
description: this.form.description,
|
description: this.form.description,
|
||||||
imageId: this.form.imageId,
|
imageId: this.form.imageId,
|
||||||
link: this.form.link,
|
link: this.form.link,
|
||||||
}),
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (res.data?.success) {
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(
|
|
||||||
errorData.message || `HTTP error! status: ${response.status}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
toast.success("Berhasil update program inovasi");
|
toast.success("Berhasil update program inovasi");
|
||||||
await programInovasi.findMany.load(); // refresh list
|
await programInovasi.findMany.load();
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(result.message || "Gagal update program inovasi");
|
toast.error(res.data?.message || "Gagal update program inovasi");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.error("Error updating program inovasi:", error);
|
console.error("Error updating program inovasi:", error);
|
||||||
|
}
|
||||||
toast.error(
|
toast.error(
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
@@ -443,7 +426,7 @@ const pejabatDesa = proxy({
|
|||||||
const templateMediaSosial = z.object({
|
const templateMediaSosial = z.object({
|
||||||
name: z.string().min(3, "Nama minimal 3 karakter"),
|
name: z.string().min(3, "Nama minimal 3 karakter"),
|
||||||
imageId: z.string().nullable().optional(),
|
imageId: z.string().nullable().optional(),
|
||||||
iconUrl: z.string().min(3, "Icon URL minimal 3 karakter"),
|
iconUrl: z.string().optional(), // ✅ Optional - tidak selalu required
|
||||||
icon: z.string().nullable().optional(),
|
icon: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -484,10 +467,15 @@ const mediaSosial = proxy({
|
|||||||
mediaSosial.findMany.load();
|
mediaSosial.findMany.load();
|
||||||
return toast.success("Sukses menambahkan");
|
return toast.success("Sukses menambahkan");
|
||||||
}
|
}
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.log(res);
|
console.log(res);
|
||||||
|
}
|
||||||
return toast.error("failed create");
|
return toast.error("failed create");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.log((error as Error).message);
|
console.log((error as Error).message);
|
||||||
|
}
|
||||||
|
toast.error("Gagal menambahkan data");
|
||||||
} finally {
|
} finally {
|
||||||
mediaSosial.create.loading = false;
|
mediaSosial.create.loading = false;
|
||||||
}
|
}
|
||||||
@@ -518,13 +506,17 @@ const mediaSosial = proxy({
|
|||||||
mediaSosial.findMany.total = res.data.total || 0;
|
mediaSosial.findMany.total = res.data.total || 0;
|
||||||
mediaSosial.findMany.totalPages = res.data.totalPages || 1;
|
mediaSosial.findMany.totalPages = res.data.totalPages || 1;
|
||||||
} else {
|
} else {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.error("Failed to load media sosial:", res.data?.message);
|
console.error("Failed to load media sosial:", res.data?.message);
|
||||||
|
}
|
||||||
mediaSosial.findMany.data = [];
|
mediaSosial.findMany.data = [];
|
||||||
mediaSosial.findMany.total = 0;
|
mediaSosial.findMany.total = 0;
|
||||||
mediaSosial.findMany.totalPages = 1;
|
mediaSosial.findMany.totalPages = 1;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.error("Error loading media sosial:", error);
|
console.error("Error loading media sosial:", error);
|
||||||
|
}
|
||||||
mediaSosial.findMany.data = [];
|
mediaSosial.findMany.data = [];
|
||||||
mediaSosial.findMany.total = 0;
|
mediaSosial.findMany.total = 0;
|
||||||
mediaSosial.findMany.totalPages = 1;
|
mediaSosial.findMany.totalPages = 1;
|
||||||
@@ -539,25 +531,32 @@ const mediaSosial = proxy({
|
|||||||
image: true;
|
image: true;
|
||||||
};
|
};
|
||||||
}> | null,
|
}> | null,
|
||||||
|
loading: false,
|
||||||
async load(id: string) {
|
async load(id: string) {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
toast.warn("ID tidak valid");
|
toast.warn("ID tidak valid");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaSosial.update.loading = true;
|
mediaSosial.findUnique.loading = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/landingpage/mediasosial/${id}`);
|
const res = await (ApiFetch.api.landingpage.mediasosial as any)[id].get();
|
||||||
if (res.ok) {
|
if (res.data?.success) {
|
||||||
const data = await res.json();
|
mediaSosial.findUnique.data = res.data.data ?? null;
|
||||||
mediaSosial.findUnique.data = data.data ?? null;
|
return res.data.data;
|
||||||
} else {
|
} else {
|
||||||
console.error("Failed to fetch media sosial:", res.statusText);
|
toast.error(res.data?.message || "Gagal memuat data media sosial");
|
||||||
mediaSosial.findUnique.data = null;
|
mediaSosial.findUnique.data = null;
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.error("Error fetching media sosial:", error);
|
console.error("Error fetching media sosial:", error);
|
||||||
|
}
|
||||||
mediaSosial.findUnique.data = null;
|
mediaSosial.findUnique.data = null;
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
mediaSosial.findUnique.loading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -568,24 +567,18 @@ const mediaSosial = proxy({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
mediaSosial.delete.loading = true;
|
mediaSosial.delete.loading = true;
|
||||||
|
const res = await (ApiFetch.api.landingpage.mediasosial as any)["del"][id].delete();
|
||||||
|
|
||||||
const response = await fetch(`/api/landingpage/mediasosial/del/${id}`, {
|
if (res.data?.success) {
|
||||||
method: "DELETE",
|
toast.success(res.data.message || "Media Sosial berhasil dihapus");
|
||||||
headers: {
|
await mediaSosial.findMany.load();
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (response.ok && result?.success) {
|
|
||||||
toast.success(result.message || "Media Sosial berhasil dihapus");
|
|
||||||
await mediaSosial.findMany.load(); // refresh list
|
|
||||||
} else {
|
} else {
|
||||||
toast.error(result?.message || "Gagal menghapus media sosial");
|
toast.error(res.data?.message || "Gagal menghapus media sosial");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.error("Gagal delete:", error);
|
console.error("Gagal delete:", error);
|
||||||
|
}
|
||||||
toast.error("Terjadi kesalahan saat menghapus media sosial");
|
toast.error("Terjadi kesalahan saat menghapus media sosial");
|
||||||
} finally {
|
} finally {
|
||||||
mediaSosial.delete.loading = false;
|
mediaSosial.delete.loading = false;
|
||||||
@@ -603,43 +596,32 @@ const mediaSosial = proxy({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaSosial.update.loading = true; // ✅ Tambahkan ini di awal
|
mediaSosial.update.loading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/landingpage/mediasosial/${id}`, {
|
const res = await (ApiFetch.api.landingpage.mediasosial as any)[id].get();
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (res.data?.success) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
const data = res.data.data;
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result?.success) {
|
|
||||||
const data = result.data;
|
|
||||||
this.id = data.id;
|
this.id = data.id;
|
||||||
this.form = {
|
this.form = {
|
||||||
name: data.name || "",
|
name: data.name || "",
|
||||||
imageId: data.imageId || null,
|
imageId: data.imageId || null,
|
||||||
iconUrl: data.iconUrl || "",
|
iconUrl: data.iconUrl || "",
|
||||||
icon: data.icon || null,
|
icon: data.icon || null,
|
||||||
|
|
||||||
};
|
};
|
||||||
return data;
|
return data;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
toast.error(res.data?.message || "Gagal mengambil data media sosial");
|
||||||
result?.message || "Gagal mengambil data media sosial"
|
return null;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error((error as Error).message);
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.error("Error loading media sosial:", error);
|
||||||
|
}
|
||||||
toast.error("Terjadi kesalahan saat mengambil data media sosial");
|
toast.error("Terjadi kesalahan saat mengambil data media sosial");
|
||||||
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
mediaSosial.update.loading = false; // ✅ Supaya berhenti loading walau error
|
mediaSosial.update.loading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -655,41 +637,25 @@ const mediaSosial = proxy({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
mediaSosial.update.loading = true;
|
mediaSosial.update.loading = true;
|
||||||
|
const res = await (ApiFetch.api.landingpage.mediasosial as any)[this.id].put({
|
||||||
const response = await fetch(
|
|
||||||
`/api/landingpage/mediasosial/${this.id}`,
|
|
||||||
{
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: this.form.name,
|
name: this.form.name,
|
||||||
imageId: this.form.imageId,
|
imageId: this.form.imageId,
|
||||||
iconUrl: this.form.iconUrl,
|
iconUrl: this.form.iconUrl,
|
||||||
icon: this.form.icon,
|
icon: this.form.icon,
|
||||||
}),
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (res.data?.success) {
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(
|
|
||||||
errorData.message || `HTTP error! status: ${response.status}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
toast.success("Berhasil update media sosial");
|
toast.success("Berhasil update media sosial");
|
||||||
await mediaSosial.findMany.load(); // refresh list
|
await mediaSosial.findMany.load();
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(result.message || "Gagal update media sosial");
|
toast.error(res.data?.message || "Gagal update media sosial");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.error("Error updating media sosial:", error);
|
console.error("Error updating media sosial:", error);
|
||||||
|
}
|
||||||
toast.error(
|
toast.error(
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
|
|||||||
@@ -57,12 +57,24 @@ const stateProfilePPID = proxy({
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
this.data = result.data;
|
this.data = result.data;
|
||||||
return result.data;
|
return result.data;
|
||||||
} else throw new Error(result.message || "Gagal memuat data profile");
|
} else {
|
||||||
|
// Jika pesan adalah "Data tidak ditemukan" atau "Belum ada data profil PPID yang aktif",
|
||||||
|
// tetap simpan sebagai error tapi tidak perlu menampilkan toast error karena ini bukan error sebenarnya
|
||||||
|
if (result.message === "Data tidak ditemukan" || result.message === "Belum ada data profil PPID yang aktif") {
|
||||||
|
this.error = result.message;
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message || "Gagal memuat data profile");
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = (err as Error).message;
|
const msg = (err as Error).message;
|
||||||
this.error = msg;
|
this.error = msg;
|
||||||
console.error("Load profile error:", msg);
|
console.error("Load profile error:", msg);
|
||||||
|
// Hanya tampilkan toast error jika bukan karena data tidak ditemukan
|
||||||
|
if (msg !== "Data tidak ditemukan" && msg !== "Belum ada data profil PPID yang aktif") {
|
||||||
toast.error("Gagal memuat data profile");
|
toast.error("Gagal memuat data profile");
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ function EditKategoriBerita() {
|
|||||||
name: '',
|
name: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.name?.trim() !== ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadKategori = async () => {
|
const loadKategori = async () => {
|
||||||
const id = params?.id as string;
|
const id = params?.id as string;
|
||||||
@@ -72,6 +79,11 @@ function EditKategoriBerita() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
if (!formData.name?.trim()) {
|
||||||
|
toast.error('Nama kategori berita wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
// update global state hanya saat submit
|
// update global state hanya saat submit
|
||||||
@@ -143,8 +155,11 @@ function EditKategoriBerita() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ function CreateKategoriBerita() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
createState.create.form.name?.trim() !== ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
createState.create.form = {
|
createState.create.form = {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -29,6 +36,11 @@ function CreateKategoriBerita() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
if (!createState.create.form.name?.trim()) {
|
||||||
|
toast.error('Nama kategori berita wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await createState.create.create();
|
await createState.create.create();
|
||||||
@@ -93,8 +105,11 @@ function CreateKategoriBerita() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ function ListKategoriBerita({ search }: { search: string }) {
|
|||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<TableTr>
|
<TableTr>
|
||||||
<TableTd colSpan={4}>
|
<TableTd colSpan={3}> {/* ✅ Match column count (3 columns) */}
|
||||||
<Center py={24}>
|
<Center py={24}>
|
||||||
<Text c="dimmed" fz="sm" lh={1.4}>
|
<Text c="dimmed" fz="sm" lh={1.4}>
|
||||||
Tidak ada data kategori berita yang cocok
|
Tidak ada data kategori berita yang cocok
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
ActionIcon,
|
ActionIcon,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
Card,
|
||||||
|
Grid,
|
||||||
Group,
|
Group,
|
||||||
Image,
|
Image,
|
||||||
Paper,
|
Paper,
|
||||||
@@ -17,7 +19,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title,
|
||||||
Loader
|
Loader,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { Dropzone } from "@mantine/dropzone";
|
import { Dropzone } from "@mantine/dropzone";
|
||||||
import {
|
import {
|
||||||
@@ -25,19 +27,51 @@ import {
|
|||||||
IconPhoto,
|
IconPhoto,
|
||||||
IconUpload,
|
IconUpload,
|
||||||
IconX,
|
IconX,
|
||||||
|
IconVideo,
|
||||||
|
IconTrash,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { useProxy } from "valtio/utils";
|
import { useProxy } from "valtio/utils";
|
||||||
|
import { convertYoutubeUrlToEmbed } from '@/app/admin/(dashboard)/desa/gallery/lib/youtube-utils';
|
||||||
|
|
||||||
|
interface ExistingImage {
|
||||||
|
id: string;
|
||||||
|
link: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BeritaData {
|
||||||
|
id: string;
|
||||||
|
judul: string;
|
||||||
|
deskripsi: string;
|
||||||
|
content: string;
|
||||||
|
kategoriBeritaId: string | null;
|
||||||
|
imageId: string | null;
|
||||||
|
image?: { link: string } | null;
|
||||||
|
images?: ExistingImage[];
|
||||||
|
linkVideo?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
function EditBerita() {
|
function EditBerita() {
|
||||||
const beritaState = useProxy(stateDashboardBerita);
|
const beritaState = useProxy(stateDashboardBerita);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
||||||
|
// Featured image state
|
||||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
|
||||||
|
// Gallery images state
|
||||||
|
const [existingGalleryImages, setExistingGalleryImages] = useState<ExistingImage[]>([]);
|
||||||
|
const [galleryFiles, setGalleryFiles] = useState<File[]>([]);
|
||||||
|
const [galleryPreviews, setGalleryPreviews] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// YouTube link state
|
||||||
|
const [youtubeLink, setYoutubeLink] = useState('');
|
||||||
|
const [originalYoutubeLink, setOriginalYoutubeLink] = useState('');
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
judul: "",
|
judul: "",
|
||||||
deskripsi: "",
|
deskripsi: "",
|
||||||
@@ -57,7 +91,24 @@ function EditBerita() {
|
|||||||
imageUrl: ""
|
imageUrl: ""
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load kategori + berita
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.judul?.trim() !== '' &&
|
||||||
|
formData.kategoriBeritaId !== '' &&
|
||||||
|
!isHtmlEmpty(formData.deskripsi) &&
|
||||||
|
(file !== null || originalData.imageId !== '') &&
|
||||||
|
!isHtmlEmpty(formData.content)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
beritaState.kategoriBerita.findMany.load();
|
beritaState.kategoriBerita.findMany.load();
|
||||||
|
|
||||||
@@ -66,7 +117,7 @@ function EditBerita() {
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await stateDashboardBerita.berita.edit.load(id);
|
const data = await stateDashboardBerita.berita.edit.load(id) as BeritaData | null;
|
||||||
if (data) {
|
if (data) {
|
||||||
setFormData({
|
setFormData({
|
||||||
judul: data.judul || "",
|
judul: data.judul || "",
|
||||||
@@ -88,6 +139,17 @@ function EditBerita() {
|
|||||||
if (data?.image?.link) {
|
if (data?.image?.link) {
|
||||||
setPreviewImage(data.image.link);
|
setPreviewImage(data.image.link);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load gallery images
|
||||||
|
if (data?.images && data.images.length > 0) {
|
||||||
|
setExistingGalleryImages(data.images);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load YouTube link
|
||||||
|
if (data?.linkVideo) {
|
||||||
|
setYoutubeLink(data.linkVideo);
|
||||||
|
setOriginalYoutubeLink(data.linkVideo);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading berita:", error);
|
console.error("Error loading berita:", error);
|
||||||
@@ -102,15 +164,74 @@ function EditBerita() {
|
|||||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGalleryDrop = (files: File[]) => {
|
||||||
|
const maxImages = 10;
|
||||||
|
const currentCount = existingGalleryImages.length + galleryFiles.length;
|
||||||
|
const availableSlots = maxImages - currentCount;
|
||||||
|
|
||||||
|
if (availableSlots <= 0) {
|
||||||
|
toast.warn('Maksimal 10 gambar untuk galeri');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newFiles = files.slice(0, availableSlots);
|
||||||
|
|
||||||
|
if (newFiles.length === 0) {
|
||||||
|
toast.warn('Tidak ada slot tersisa untuk gambar galeri');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setGalleryFiles([...galleryFiles, ...newFiles]);
|
||||||
|
|
||||||
|
const newPreviews = newFiles.map((f) => URL.createObjectURL(f));
|
||||||
|
setGalleryPreviews([...galleryPreviews, ...newPreviews]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeGalleryImage = (index: number, isExisting: boolean = false) => {
|
||||||
|
if (isExisting) {
|
||||||
|
setExistingGalleryImages(existingGalleryImages.filter((_, i) => i !== index));
|
||||||
|
} else {
|
||||||
|
setGalleryFiles(galleryFiles.filter((_, i) => i !== index));
|
||||||
|
setGalleryPreviews(galleryPreviews.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
if (!formData.judul?.trim()) {
|
||||||
|
toast.error('Judul wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.kategoriBeritaId) {
|
||||||
|
toast.error('Kategori wajib dipilih');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHtmlEmpty(formData.deskripsi)) {
|
||||||
|
toast.error('Deskripsi singkat wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file && !originalData.imageId) {
|
||||||
|
toast.error('Gambar utama wajib dipilih');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHtmlEmpty(formData.content)) {
|
||||||
|
toast.error('Konten wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
// Update global state hanya sekali di sini
|
|
||||||
|
// Update global state
|
||||||
beritaState.berita.edit.form = {
|
beritaState.berita.edit.form = {
|
||||||
...beritaState.berita.edit.form,
|
...beritaState.berita.edit.form,
|
||||||
...formData,
|
...formData,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Upload new featured image if changed
|
||||||
if (file) {
|
if (file) {
|
||||||
const res = await ApiFetch.api.fileStorage.create.post({
|
const res = await ApiFetch.api.fileStorage.create.post({
|
||||||
file,
|
file,
|
||||||
@@ -119,12 +240,33 @@ function EditBerita() {
|
|||||||
const uploaded = res.data?.data;
|
const uploaded = res.data?.data;
|
||||||
|
|
||||||
if (!uploaded?.id) {
|
if (!uploaded?.id) {
|
||||||
return toast.error("Gagal upload gambar");
|
return toast.error("Gagal upload gambar utama");
|
||||||
}
|
}
|
||||||
|
|
||||||
beritaState.berita.edit.form.imageId = uploaded.id;
|
beritaState.berita.edit.form.imageId = uploaded.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Upload new gallery images
|
||||||
|
const newGalleryIds: string[] = [];
|
||||||
|
for (const galleryFile of galleryFiles) {
|
||||||
|
const galleryRes = await ApiFetch.api.fileStorage.create.post({
|
||||||
|
file: galleryFile,
|
||||||
|
name: galleryFile.name,
|
||||||
|
});
|
||||||
|
const galleryUploaded = galleryRes.data?.data;
|
||||||
|
if (galleryUploaded?.id) {
|
||||||
|
newGalleryIds.push(galleryUploaded.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine existing (not removed) and new gallery images
|
||||||
|
const remainingExistingIds = existingGalleryImages.map(img => img.id);
|
||||||
|
beritaState.berita.edit.form.imageIds = [...remainingExistingIds, ...newGalleryIds];
|
||||||
|
|
||||||
|
// Set YouTube link
|
||||||
|
const embedLink = convertYoutubeUrlToEmbed(youtubeLink);
|
||||||
|
beritaState.berita.edit.form.linkVideo = embedLink || '';
|
||||||
|
|
||||||
await beritaState.berita.edit.update();
|
await beritaState.berita.edit.update();
|
||||||
toast.success("Berita berhasil diperbarui!");
|
toast.success("Berita berhasil diperbarui!");
|
||||||
router.push("/admin/desa/berita/list-berita");
|
router.push("/admin/desa/berita/list-berita");
|
||||||
@@ -146,9 +288,12 @@ function EditBerita() {
|
|||||||
});
|
});
|
||||||
setPreviewImage(originalData.imageUrl || null);
|
setPreviewImage(originalData.imageUrl || null);
|
||||||
setFile(null);
|
setFile(null);
|
||||||
|
setYoutubeLink(originalYoutubeLink);
|
||||||
toast.info("Form dikembalikan ke data awal");
|
toast.info("Form dikembalikan ke data awal");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const embedLink = convertYoutubeUrlToEmbed(youtubeLink);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -176,6 +321,7 @@ function EditBerita() {
|
|||||||
style={{ border: "1px solid #e0e0e0" }}
|
style={{ border: "1px solid #e0e0e0" }}
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
|
{/* Judul */}
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Judul"
|
label="Judul"
|
||||||
placeholder="Masukkan judul"
|
placeholder="Masukkan judul"
|
||||||
@@ -184,6 +330,7 @@ function EditBerita() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Kategori */}
|
||||||
<Select
|
<Select
|
||||||
value={formData.kategoriBeritaId}
|
value={formData.kategoriBeritaId}
|
||||||
onChange={(val) => handleChange("kategoriBeritaId", val || "")}
|
onChange={(val) => handleChange("kategoriBeritaId", val || "")}
|
||||||
@@ -198,9 +345,9 @@ function EditBerita() {
|
|||||||
clearable
|
clearable
|
||||||
searchable
|
searchable
|
||||||
required
|
required
|
||||||
error={!formData.kategoriBeritaId ? "Pilih kategori" : undefined}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Deskripsi */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz="sm" fw="bold">
|
<Text fz="sm" fw="bold">
|
||||||
Deskripsi Singkat
|
Deskripsi Singkat
|
||||||
@@ -213,11 +360,10 @@ function EditBerita() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Featured Image */}
|
||||||
{/* Upload Gambar */}
|
|
||||||
<Box>
|
<Box>
|
||||||
<Text fw="bold" fz="sm" mb={6}>
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
Gambar Berita
|
Gambar Utama (Featured)
|
||||||
</Text>
|
</Text>
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onDrop={(files) => {
|
onDrop={(files) => {
|
||||||
@@ -231,17 +377,13 @@ function EditBerita() {
|
|||||||
toast.error("File tidak valid, gunakan format gambar")
|
toast.error("File tidak valid, gunakan format gambar")
|
||||||
}
|
}
|
||||||
maxSize={5 * 1024 ** 2}
|
maxSize={5 * 1024 ** 2}
|
||||||
accept={{ "image/*": [] }}
|
accept={{ "image/*": ['.jpeg', '.jpg', '.png', '.webp'] }}
|
||||||
radius="md"
|
radius="md"
|
||||||
p="xl"
|
p="xl"
|
||||||
>
|
>
|
||||||
<Group justify="center" gap="xl" mih={180}>
|
<Group justify="center" gap="xl" mih={180}>
|
||||||
<Dropzone.Accept>
|
<Dropzone.Accept>
|
||||||
<IconUpload
|
<IconUpload size={48} color={colors["blue-button"]} stroke={1.5} />
|
||||||
size={48}
|
|
||||||
color={colors["blue-button"]}
|
|
||||||
stroke={1.5}
|
|
||||||
/>
|
|
||||||
</Dropzone.Accept>
|
</Dropzone.Accept>
|
||||||
<Dropzone.Reject>
|
<Dropzone.Reject>
|
||||||
<IconX size={48} color="red" stroke={1.5} />
|
<IconX size={48} color="red" stroke={1.5} />
|
||||||
@@ -249,14 +391,6 @@ function EditBerita() {
|
|||||||
<Dropzone.Idle>
|
<Dropzone.Idle>
|
||||||
<IconPhoto size={48} color="#868e96" stroke={1.5} />
|
<IconPhoto size={48} color="#868e96" stroke={1.5} />
|
||||||
</Dropzone.Idle>
|
</Dropzone.Idle>
|
||||||
<Stack gap="xs" align="center">
|
|
||||||
<Text size="md" fw={500}>
|
|
||||||
Seret gambar atau klik untuk memilih file
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Dropzone>
|
</Dropzone>
|
||||||
|
|
||||||
@@ -285,9 +419,7 @@ function EditBerita() {
|
|||||||
setPreviewImage(null);
|
setPreviewImage(null);
|
||||||
setFile(null);
|
setFile(null);
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
|
||||||
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<IconX size={14} />
|
<IconX size={14} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -295,6 +427,138 @@ function EditBerita() {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Gallery Images */}
|
||||||
|
<Box>
|
||||||
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
|
Galeri Gambar (Opsional - Maksimal 10)
|
||||||
|
</Text>
|
||||||
|
<Dropzone
|
||||||
|
onDrop={handleGalleryDrop}
|
||||||
|
onReject={() => toast.error("File tidak valid, gunakan format gambar")}
|
||||||
|
maxSize={5 * 1024 ** 2}
|
||||||
|
accept={{ "image/*": ['.jpeg', '.jpg', '.png', '.webp'] }}
|
||||||
|
radius="md"
|
||||||
|
p="md"
|
||||||
|
multiple
|
||||||
|
>
|
||||||
|
<Group justify="center" gap="xl" mih={120}>
|
||||||
|
<Dropzone.Accept>
|
||||||
|
<IconUpload size={40} color={colors["blue-button"]} stroke={1.5} />
|
||||||
|
</Dropzone.Accept>
|
||||||
|
<Dropzone.Reject>
|
||||||
|
<IconX size={40} color="red" stroke={1.5} />
|
||||||
|
</Dropzone.Reject>
|
||||||
|
<Dropzone.Idle>
|
||||||
|
<IconPhoto size={40} color="#868e96" stroke={1.5} />
|
||||||
|
</Dropzone.Idle>
|
||||||
|
</Group>
|
||||||
|
<Text ta="center" mt="sm" size="xs" color="dimmed">
|
||||||
|
Seret gambar untuk menambahkan ke galeri
|
||||||
|
</Text>
|
||||||
|
</Dropzone>
|
||||||
|
|
||||||
|
{/* Existing Gallery Images */}
|
||||||
|
{existingGalleryImages.length > 0 && (
|
||||||
|
<Box mt="sm">
|
||||||
|
<Text fz="xs" fw="bold" mb={6} c="dimmed">
|
||||||
|
Gambar Existing ({existingGalleryImages.length})
|
||||||
|
</Text>
|
||||||
|
<Grid gutter="sm">
|
||||||
|
{existingGalleryImages.map((img, index) => (
|
||||||
|
<Grid.Col span={4} key={img.id}>
|
||||||
|
<Card p="xs" radius="md" withBorder>
|
||||||
|
<Image src={img.link} alt={img.name} radius="sm" height={100} fit="cover" />
|
||||||
|
<ActionIcon
|
||||||
|
variant="filled"
|
||||||
|
color="red"
|
||||||
|
radius="xl"
|
||||||
|
size="sm"
|
||||||
|
pos="absolute"
|
||||||
|
top={5}
|
||||||
|
right={5}
|
||||||
|
onClick={() => removeGalleryImage(index, true)}
|
||||||
|
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
|
||||||
|
>
|
||||||
|
<IconTrash size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* New Gallery Images */}
|
||||||
|
{galleryPreviews.length > 0 && (
|
||||||
|
<Box mt="sm">
|
||||||
|
<Text fz="xs" fw="bold" mb={6} c="dimmed">
|
||||||
|
Gambar Baru ({galleryPreviews.length})
|
||||||
|
</Text>
|
||||||
|
<Grid gutter="sm">
|
||||||
|
{galleryPreviews.map((preview, index) => (
|
||||||
|
<Grid.Col span={4} key={index}>
|
||||||
|
<Card p="xs" radius="md" withBorder>
|
||||||
|
<Image src={preview} alt={`New ${index}`} radius="sm" height={100} fit="cover" />
|
||||||
|
<ActionIcon
|
||||||
|
variant="filled"
|
||||||
|
color="red"
|
||||||
|
radius="xl"
|
||||||
|
size="sm"
|
||||||
|
pos="absolute"
|
||||||
|
top={5}
|
||||||
|
right={5}
|
||||||
|
onClick={() => removeGalleryImage(index, false)}
|
||||||
|
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
|
||||||
|
>
|
||||||
|
<IconTrash size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* YouTube Video */}
|
||||||
|
<Box>
|
||||||
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
|
Link Video YouTube (Opsional)
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
placeholder="https://www.youtube.com/watch?v=..."
|
||||||
|
value={youtubeLink}
|
||||||
|
onChange={(e) => setYoutubeLink(e.currentTarget.value)}
|
||||||
|
leftSection={<IconVideo size={18} />}
|
||||||
|
rightSection={
|
||||||
|
youtubeLink && (
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={() => setYoutubeLink('')}
|
||||||
|
>
|
||||||
|
<IconX size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{embedLink && (
|
||||||
|
<Box mt="sm" pos="relative">
|
||||||
|
<iframe
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
width: '100%',
|
||||||
|
height: 250,
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
}}
|
||||||
|
src={embedLink}
|
||||||
|
title="Preview Video"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Konten */}
|
{/* Konten */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz="sm" fw="bold">
|
<Text fz="sm" fw="bold">
|
||||||
@@ -308,9 +572,8 @@ function EditBerita() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Action */}
|
{/* Action Buttons */}
|
||||||
<Group justify="right">
|
<Group justify="right">
|
||||||
{/* Tombol Batal */}
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
color="gray"
|
color="gray"
|
||||||
@@ -320,14 +583,15 @@ function EditBerita() {
|
|||||||
>
|
>
|
||||||
Batal
|
Batal
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Tombol Simpan */}
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text } from '@mantine/core';
|
import { Box, Button, Card, Grid, Group, Image, Paper, Skeleton, Stack, Text, Badge, AspectRatio } from '@mantine/core';
|
||||||
import { useShallowEffect } from '@mantine/hooks';
|
import { useShallowEffect } from '@mantine/hooks';
|
||||||
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
|
import { IconArrowBack, IconEdit, IconTrash, IconVideo } from '@tabler/icons-react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useProxy } from 'valtio/utils';
|
import { useProxy } from 'valtio/utils';
|
||||||
@@ -10,6 +10,23 @@ import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirma
|
|||||||
import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita';
|
import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita';
|
||||||
import colors from '@/con/colors';
|
import colors from '@/con/colors';
|
||||||
|
|
||||||
|
interface ExistingImage {
|
||||||
|
id: string;
|
||||||
|
link: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BeritaDetail {
|
||||||
|
id: string;
|
||||||
|
judul: string;
|
||||||
|
deskripsi: string;
|
||||||
|
content: string;
|
||||||
|
image?: { link: string } | null;
|
||||||
|
images?: ExistingImage[];
|
||||||
|
linkVideo?: string | null;
|
||||||
|
kategoriBerita?: { name: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
function DetailBerita() {
|
function DetailBerita() {
|
||||||
const beritaState = useProxy(stateDashboardBerita);
|
const beritaState = useProxy(stateDashboardBerita);
|
||||||
const [modalHapus, setModalHapus] = useState(false);
|
const [modalHapus, setModalHapus] = useState(false);
|
||||||
@@ -38,7 +55,7 @@ function DetailBerita() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = beritaState.berita.findUnique.data;
|
const data = beritaState.berita.findUnique.data as unknown as BeritaDetail;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box px={{ base: 0, md: 'xs' }} py="xs">
|
<Box px={{ base: 0, md: 'xs' }} py="xs">
|
||||||
@@ -68,49 +85,107 @@ function DetailBerita() {
|
|||||||
|
|
||||||
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
|
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
|
{/* Kategori */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz="lg" fw="bold">Kategori</Text>
|
<Text fz="lg" fw="bold">Kategori</Text>
|
||||||
<Text fz="md" c="dimmed">{data.kategoriBerita?.name || '-'}</Text>
|
<Text fz="md" c="dimmed">{data.kategoriBerita?.name || '-'}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Judul */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz="lg" fw="bold">Judul</Text>
|
<Text fz="lg" fw="bold">Judul</Text>
|
||||||
<Text fz="md" c="dimmed">{data.judul || '-'}</Text>
|
<Text fz="md" c="dimmed">{data.judul || '-'}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Deskripsi */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz="lg" fw="bold">Deskripsi</Text>
|
<Text fz="lg" fw="bold">Deskripsi</Text>
|
||||||
<Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }} />
|
<Text
|
||||||
|
fz="md"
|
||||||
|
c="dimmed"
|
||||||
|
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
|
||||||
|
dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Gambar Utama (Featured) */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz="lg" fw="bold">Gambar</Text>
|
<Text fz="lg" fw="bold">Gambar Utama</Text>
|
||||||
{data.image?.link ? (
|
{data.image?.link ? (
|
||||||
<Image
|
<Image
|
||||||
src={data.image.link}
|
src={data.image.link}
|
||||||
alt={data.judul || 'Gambar Berita'}
|
alt={data.judul || 'Gambar Berita'}
|
||||||
w={200}
|
w={{ base: '100%', md: 400 }}
|
||||||
h={200}
|
h={300}
|
||||||
radius="md"
|
radius="md"
|
||||||
fit="cover"
|
fit="cover"
|
||||||
loading='lazy'
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Text fz="sm" c="dimmed">Tidak ada gambar</Text>
|
<Text fz="sm" c="dimmed">Tidak ada gambar utama</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Gallery Images */}
|
||||||
|
{data.images && data.images.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Group gap="xs" mb="sm">
|
||||||
|
<Text fz="lg" fw="bold">Galeri Gambar</Text>
|
||||||
|
<Badge color="blue" variant="light">
|
||||||
|
{data.images.length}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<Grid gutter="md">
|
||||||
|
{data.images.map((img, index) => (
|
||||||
|
<Grid.Col span={{ base: 6, md: 4 }} key={img.id}>
|
||||||
|
<Card p="xs" radius="md" withBorder>
|
||||||
|
<Image
|
||||||
|
src={img.link}
|
||||||
|
alt={img.name || `Gallery ${index + 1}`}
|
||||||
|
h={150}
|
||||||
|
radius="sm"
|
||||||
|
fit="cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* YouTube Video */}
|
||||||
|
{data.linkVideo && (
|
||||||
|
<Box>
|
||||||
|
<Group gap="xs" mb="sm">
|
||||||
|
<Text fz="lg" fw="bold">Video YouTube</Text>
|
||||||
|
<IconVideo size={20} color={colors['blue-button']} />
|
||||||
|
</Group>
|
||||||
|
<AspectRatio ratio={16 / 9} mah={400}>
|
||||||
|
<iframe
|
||||||
|
src={data.linkVideo}
|
||||||
|
title="YouTube Video"
|
||||||
|
allowFullScreen
|
||||||
|
style={{ borderRadius: 10, border: '1px solid #ddd' }}
|
||||||
|
/>
|
||||||
|
</AspectRatio>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Konten */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz="lg" fw="bold">Konten</Text>
|
<Text fz="lg" fw="bold">Konten</Text>
|
||||||
|
<Paper bg="white" p="md" radius="md" mt="xs">
|
||||||
<Text
|
<Text
|
||||||
fz="md"
|
fz="md"
|
||||||
c="dimmed"
|
c="dimmed"
|
||||||
dangerouslySetInnerHTML={{ __html: data.content || '-' }}
|
dangerouslySetInnerHTML={{ __html: data.content || '-' }}
|
||||||
/>
|
/>
|
||||||
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Action Button */}
|
{/* Action Buttons */}
|
||||||
<Group gap="sm">
|
<Group gap="sm" mt="md">
|
||||||
<Button
|
<Button
|
||||||
color="red"
|
color="red"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -120,8 +195,9 @@ function DetailBerita() {
|
|||||||
variant="light"
|
variant="light"
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
leftSection={<IconTrash size={20} />}
|
||||||
>
|
>
|
||||||
<IconTrash size={20} />
|
Hapus
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -130,8 +206,9 @@ function DetailBerita() {
|
|||||||
variant="light"
|
variant="light"
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
leftSection={<IconEdit size={20} />}
|
||||||
>
|
>
|
||||||
<IconEdit size={20} />
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -15,23 +15,53 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title,
|
||||||
Loader,
|
Loader,
|
||||||
ActionIcon
|
ActionIcon,
|
||||||
|
Grid,
|
||||||
|
Card,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { Dropzone } from '@mantine/dropzone';
|
import { Dropzone } from '@mantine/dropzone';
|
||||||
import { useShallowEffect } from '@mantine/hooks';
|
import { useShallowEffect } from '@mantine/hooks';
|
||||||
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
|
import { IconArrowBack, IconPhoto, IconUpload, IconX, IconVideo, IconTrash } from '@tabler/icons-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { useProxy } from 'valtio/utils';
|
import { useProxy } from 'valtio/utils';
|
||||||
|
import { convertYoutubeUrlToEmbed } from '@/app/admin/(dashboard)/desa/gallery/lib/youtube-utils';
|
||||||
|
|
||||||
export default function CreateBerita() {
|
export default function CreateBerita() {
|
||||||
const beritaState = useProxy(stateDashboardBerita);
|
const beritaState = useProxy(stateDashboardBerita);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Featured image state
|
||||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const router = useRouter();
|
|
||||||
|
// Gallery images state
|
||||||
|
const [galleryFiles, setGalleryFiles] = useState<File[]>([]);
|
||||||
|
const [galleryPreviews, setGalleryPreviews] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// YouTube link state
|
||||||
|
const [youtubeLink, setYoutubeLink] = useState('');
|
||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
beritaState.berita.create.form.judul?.trim() !== '' &&
|
||||||
|
beritaState.berita.create.form.kategoriBeritaId !== '' &&
|
||||||
|
!isHtmlEmpty(beritaState.berita.create.form.deskripsi) &&
|
||||||
|
file !== null &&
|
||||||
|
!isHtmlEmpty(beritaState.berita.create.form.content)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
useShallowEffect(() => {
|
useShallowEffect(() => {
|
||||||
beritaState.kategoriBerita.findMany.load();
|
beritaState.kategoriBerita.findMany.load();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -43,29 +73,96 @@ export default function CreateBerita() {
|
|||||||
kategoriBeritaId: '',
|
kategoriBeritaId: '',
|
||||||
imageId: '',
|
imageId: '',
|
||||||
content: '',
|
content: '',
|
||||||
|
imageIds: [],
|
||||||
|
linkVideo: '',
|
||||||
};
|
};
|
||||||
setPreviewImage(null);
|
setPreviewImage(null);
|
||||||
setFile(null);
|
setFile(null);
|
||||||
|
setGalleryFiles([]);
|
||||||
|
setGalleryPreviews([]);
|
||||||
|
setYoutubeLink('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGalleryDrop = (files: File[]) => {
|
||||||
|
const newFiles = files.filter(
|
||||||
|
(_, index) => galleryFiles.length + index < 10 // Max 10 images
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newFiles.length === 0) {
|
||||||
|
toast.warn('Maksimal 10 gambar untuk galeri');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setGalleryFiles([...galleryFiles, ...newFiles]);
|
||||||
|
|
||||||
|
const newPreviews = newFiles.map((f) => URL.createObjectURL(f));
|
||||||
|
setGalleryPreviews([...galleryPreviews, ...newPreviews]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeGalleryImage = (index: number) => {
|
||||||
|
setGalleryFiles(galleryFiles.filter((_, i) => i !== index));
|
||||||
|
setGalleryPreviews(galleryPreviews.filter((_, i) => i !== index));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
try {
|
if (!beritaState.berita.create.form.judul?.trim()) {
|
||||||
setIsSubmitting(true);
|
toast.error('Judul wajib diisi');
|
||||||
if (!file) {
|
return;
|
||||||
return toast.warn('Silakan pilih file gambar terlebih dahulu');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await ApiFetch.api.fileStorage.create.post({
|
if (!beritaState.berita.create.form.kategoriBeritaId) {
|
||||||
|
toast.error('Kategori wajib dipilih');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHtmlEmpty(beritaState.berita.create.form.deskripsi)) {
|
||||||
|
toast.error('Deskripsi singkat wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
toast.error('Gambar utama wajib dipilih');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHtmlEmpty(beritaState.berita.create.form.content)) {
|
||||||
|
toast.error('Konten wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
// Upload featured image
|
||||||
|
const featuredRes = await ApiFetch.api.fileStorage.create.post({
|
||||||
file,
|
file,
|
||||||
name: file.name,
|
name: file.name,
|
||||||
});
|
});
|
||||||
|
const featuredUploaded = featuredRes.data?.data;
|
||||||
const uploaded = res.data?.data;
|
if (!featuredUploaded?.id) {
|
||||||
if (!uploaded?.id) {
|
return toast.error('Gagal mengunggah gambar utama');
|
||||||
return toast.error('Gagal mengunggah gambar, silakan coba lagi');
|
|
||||||
}
|
}
|
||||||
|
beritaState.berita.create.form.imageId = featuredUploaded.id;
|
||||||
|
|
||||||
beritaState.berita.create.form.imageId = uploaded.id;
|
// Upload gallery images
|
||||||
|
const galleryIds: string[] = [];
|
||||||
|
for (const galleryFile of galleryFiles) {
|
||||||
|
const galleryRes = await ApiFetch.api.fileStorage.create.post({
|
||||||
|
file: galleryFile,
|
||||||
|
name: galleryFile.name,
|
||||||
|
});
|
||||||
|
const galleryUploaded = galleryRes.data?.data;
|
||||||
|
if (galleryUploaded?.id) {
|
||||||
|
galleryIds.push(galleryUploaded.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
beritaState.berita.create.form.imageIds = galleryIds;
|
||||||
|
|
||||||
|
// Set YouTube link if provided
|
||||||
|
const embedLink = convertYoutubeUrlToEmbed(youtubeLink);
|
||||||
|
if (embedLink) {
|
||||||
|
beritaState.berita.create.form.linkVideo = embedLink;
|
||||||
|
}
|
||||||
|
|
||||||
await beritaState.berita.create.create();
|
await beritaState.berita.create.create();
|
||||||
|
|
||||||
@@ -79,16 +176,13 @@ export default function CreateBerita() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const embedLink = convertYoutubeUrlToEmbed(youtubeLink);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||||
{/* Header dengan tombol kembali */}
|
{/* Header */}
|
||||||
<Group mb="md">
|
<Group mb="md">
|
||||||
<Button
|
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
|
||||||
variant="subtle"
|
|
||||||
onClick={() => router.back()}
|
|
||||||
p="xs"
|
|
||||||
radius="md"
|
|
||||||
>
|
|
||||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||||
</Button>
|
</Button>
|
||||||
<Title order={4} ml="sm" c="dark">
|
<Title order={4} ml="sm" c="dark">
|
||||||
@@ -105,6 +199,7 @@ export default function CreateBerita() {
|
|||||||
style={{ border: '1px solid #e0e0e0' }}
|
style={{ border: '1px solid #e0e0e0' }}
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
|
{/* Judul */}
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Judul"
|
label="Judul"
|
||||||
placeholder="Masukkan judul berita"
|
placeholder="Masukkan judul berita"
|
||||||
@@ -113,6 +208,7 @@ export default function CreateBerita() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Kategori */}
|
||||||
<Select
|
<Select
|
||||||
label="Kategori"
|
label="Kategori"
|
||||||
placeholder="Pilih kategori"
|
placeholder="Pilih kategori"
|
||||||
@@ -139,6 +235,7 @@ export default function CreateBerita() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Deskripsi */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz="sm" fw="bold" mb={6}>
|
<Text fz="sm" fw="bold" mb={6}>
|
||||||
Deskripsi Singkat
|
Deskripsi Singkat
|
||||||
@@ -151,9 +248,10 @@ export default function CreateBerita() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Featured Image */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fw="bold" fz="sm" mb={6}>
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
Gambar Berita
|
Gambar Utama (Featured)
|
||||||
</Text>
|
</Text>
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onDrop={(files) => {
|
onDrop={(files) => {
|
||||||
@@ -189,17 +287,11 @@ export default function CreateBerita() {
|
|||||||
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
|
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
|
||||||
<Image
|
<Image
|
||||||
src={previewImage}
|
src={previewImage}
|
||||||
alt="Preview Gambar"
|
alt="Preview Gambar Utama"
|
||||||
radius="md"
|
radius="md"
|
||||||
style={{
|
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
|
||||||
maxHeight: 200,
|
|
||||||
objectFit: 'contain',
|
|
||||||
border: '1px solid #ddd',
|
|
||||||
}}
|
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Tombol hapus (pojok kanan atas) */}
|
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="filled"
|
variant="filled"
|
||||||
color="red"
|
color="red"
|
||||||
@@ -212,9 +304,7 @@ export default function CreateBerita() {
|
|||||||
setPreviewImage(null);
|
setPreviewImage(null);
|
||||||
setFile(null);
|
setFile(null);
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
|
||||||
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<IconX size={14} />
|
<IconX size={14} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -222,6 +312,102 @@ export default function CreateBerita() {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Gallery Images */}
|
||||||
|
<Box>
|
||||||
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
|
Galeri Gambar (Opsional - Maksimal 10)
|
||||||
|
</Text>
|
||||||
|
<Dropzone
|
||||||
|
onDrop={handleGalleryDrop}
|
||||||
|
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
|
||||||
|
maxSize={5 * 1024 ** 2}
|
||||||
|
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
|
||||||
|
radius="md"
|
||||||
|
p="md"
|
||||||
|
multiple
|
||||||
|
>
|
||||||
|
<Group justify="center" gap="xl" mih={120}>
|
||||||
|
<Dropzone.Accept>
|
||||||
|
<IconUpload size={40} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||||
|
</Dropzone.Accept>
|
||||||
|
<Dropzone.Reject>
|
||||||
|
<IconX size={40} color="var(--mantine-color-red-6)" stroke={1.5} />
|
||||||
|
</Dropzone.Reject>
|
||||||
|
<Dropzone.Idle>
|
||||||
|
<IconPhoto size={40} color="var(--mantine-color-dimmed)" stroke={1.5} />
|
||||||
|
</Dropzone.Idle>
|
||||||
|
</Group>
|
||||||
|
<Text ta="center" mt="sm" size="xs" color="dimmed">
|
||||||
|
Seret gambar atau klik untuk menambahkan ke galeri
|
||||||
|
</Text>
|
||||||
|
</Dropzone>
|
||||||
|
|
||||||
|
{galleryPreviews.length > 0 && (
|
||||||
|
<Grid mt="sm" gutter="sm">
|
||||||
|
{galleryPreviews.map((preview, index) => (
|
||||||
|
<Grid.Col span={4} key={index}>
|
||||||
|
<Card p="xs" radius="md" withBorder>
|
||||||
|
<Image src={preview} alt={`Gallery ${index}`} radius="sm" height={100} fit="cover" />
|
||||||
|
<ActionIcon
|
||||||
|
variant="filled"
|
||||||
|
color="red"
|
||||||
|
radius="xl"
|
||||||
|
size="sm"
|
||||||
|
pos="absolute"
|
||||||
|
top={5}
|
||||||
|
right={5}
|
||||||
|
onClick={() => removeGalleryImage(index)}
|
||||||
|
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
|
||||||
|
>
|
||||||
|
<IconTrash size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* YouTube Video */}
|
||||||
|
<Box>
|
||||||
|
<Text fw="bold" fz="sm" mb={6}>
|
||||||
|
Link Video YouTube (Opsional)
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
placeholder="https://www.youtube.com/watch?v=..."
|
||||||
|
value={youtubeLink}
|
||||||
|
onChange={(e) => setYoutubeLink(e.currentTarget.value)}
|
||||||
|
leftSection={<IconVideo size={18} />}
|
||||||
|
rightSection={
|
||||||
|
youtubeLink && (
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={() => setYoutubeLink('')}
|
||||||
|
>
|
||||||
|
<IconX size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{embedLink && (
|
||||||
|
<Box mt="sm" pos="relative">
|
||||||
|
<iframe
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
width: '100%',
|
||||||
|
height: 250,
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
}}
|
||||||
|
src={embedLink}
|
||||||
|
title="Preview Video"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Konten */}
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz="sm" fw="bold" mb={6}>
|
<Text fz="sm" fw="bold" mb={6}>
|
||||||
Konten
|
Konten
|
||||||
@@ -234,6 +420,7 @@ export default function CreateBerita() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
<Group justify="right">
|
<Group justify="right">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -244,14 +431,15 @@ export default function CreateBerita() {
|
|||||||
>
|
>
|
||||||
Reset
|
Reset
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Tombol Simpan */}
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ function ListBerita({ search }: { search: string }) {
|
|||||||
<Pagination
|
<Pagination
|
||||||
value={page}
|
value={page}
|
||||||
onChange={(newPage) => {
|
onChange={(newPage) => {
|
||||||
load(newPage, 10);
|
load(newPage, 10, debouncedSearch); // ✅ Include search parameter
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
}}
|
}}
|
||||||
total={totalPages}
|
total={totalPages}
|
||||||
|
|||||||
@@ -45,6 +45,22 @@ function EditFoto() {
|
|||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.name?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(formData.deskripsi) &&
|
||||||
|
(file !== null || originalData.imagesId !== '') // Either a new file is selected or an existing image exists
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const [originalData, setOriginalData] = useState({
|
const [originalData, setOriginalData] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
deskripsi: "",
|
deskripsi: "",
|
||||||
@@ -94,6 +110,21 @@ function EditFoto() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
if (!formData.name?.trim()) {
|
||||||
|
toast.error('Judul wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHtmlEmpty(formData.deskripsi)) {
|
||||||
|
toast.error('Deskripsi wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file && !originalData.imagesId) {
|
||||||
|
toast.error('Gambar wajib dipilih');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
// Update global state hanya sekali di sini
|
// Update global state hanya sekali di sini
|
||||||
@@ -285,8 +316,11 @@ function EditFoto() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -30,6 +30,22 @@ function CreateFoto() {
|
|||||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
FotoState.create.form.name?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(FotoState.create.form.deskripsi) &&
|
||||||
|
file !== null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
FotoState.create.form = {
|
FotoState.create.form = {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -41,6 +57,21 @@ function CreateFoto() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
if (!FotoState.create.form.name?.trim()) {
|
||||||
|
toast.error('Judul wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHtmlEmpty(FotoState.create.form.deskripsi)) {
|
||||||
|
toast.error('Deskripsi wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
toast.error('Gambar wajib dipilih');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
if (!file) {
|
if (!file) {
|
||||||
@@ -210,8 +241,11 @@ function CreateFoto() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -28,6 +28,24 @@ function EditVideo() {
|
|||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
const embedLink = convertYoutubeUrlToEmbed(formData.linkVideo);
|
||||||
|
return (
|
||||||
|
formData.name?.trim() !== '' &&
|
||||||
|
formData.linkVideo?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(formData.deskripsi) &&
|
||||||
|
embedLink !== null // Make sure the embed link is valid
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const [originalData, setOriginalData] = useState({
|
const [originalData, setOriginalData] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
deskripsi: "",
|
deskripsi: "",
|
||||||
@@ -86,6 +104,21 @@ function EditVideo() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
if (!formData.name?.trim()) {
|
||||||
|
toast.error('Judul wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.linkVideo?.trim()) {
|
||||||
|
toast.error('Link YouTube wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHtmlEmpty(formData.deskripsi)) {
|
||||||
|
toast.error('Deskripsi wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
const converted = convertYoutubeUrlToEmbed(formData.linkVideo);
|
const converted = convertYoutubeUrlToEmbed(formData.linkVideo);
|
||||||
@@ -218,8 +251,11 @@ function EditVideo() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -28,6 +28,23 @@ function CreateVideo() {
|
|||||||
const embedLink = convertYoutubeUrlToEmbed(link);
|
const embedLink = convertYoutubeUrlToEmbed(link);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
videoState.create.form.name?.trim() !== '' &&
|
||||||
|
link.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(videoState.create.form.deskripsi) &&
|
||||||
|
embedLink !== null // Make sure the embed link is valid
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
videoState.create.form = {
|
videoState.create.form = {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -38,6 +55,26 @@ function CreateVideo() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
if (!videoState.create.form.name?.trim()) {
|
||||||
|
toast.error('Judul wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!link.trim()) {
|
||||||
|
toast.error('Link YouTube wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHtmlEmpty(videoState.create.form.deskripsi)) {
|
||||||
|
toast.error('Deskripsi wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!embedLink) {
|
||||||
|
toast.error('Link YouTube tidak valid. Pastikan formatnya benar.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
if (!embedLink) {
|
if (!embedLink) {
|
||||||
@@ -168,8 +205,11 @@ function CreateVideo() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -35,6 +35,21 @@ function EditPelayananPendudukNonPermanent() {
|
|||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.name?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(formData.deskripsi)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const [originalData, setOriginalData] = useState({
|
const [originalData, setOriginalData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
deskripsi: '',
|
deskripsi: '',
|
||||||
@@ -86,6 +101,16 @@ function EditPelayananPendudukNonPermanent() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
if (!formData.name?.trim()) {
|
||||||
|
toast.error('Judul wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHtmlEmpty(formData.deskripsi)) {
|
||||||
|
toast.error('Deskripsi wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
if (!statePendudukNonPermanent.findById.data) return;
|
if (!statePendudukNonPermanent.findById.data) return;
|
||||||
@@ -173,8 +198,11 @@ function EditPelayananPendudukNonPermanent() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -36,6 +36,22 @@ function EditPelayananPerizinanBerusaha() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.name?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(formData.deskripsi)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const [originalData, setOriginalData] = useState({
|
const [originalData, setOriginalData] = useState({
|
||||||
id: '',
|
id: '',
|
||||||
name: '',
|
name: '',
|
||||||
@@ -102,6 +118,21 @@ function EditPelayananPerizinanBerusaha() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
if (!formData.name?.trim()) {
|
||||||
|
toast.error('Judul wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHtmlEmpty(formData.deskripsi)) {
|
||||||
|
toast.error('Deskripsi wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.link?.trim()) {
|
||||||
|
toast.error('Link wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
await state.update.update(formData);
|
await state.update.update(formData);
|
||||||
@@ -192,8 +223,11 @@ function EditPelayananPerizinanBerusaha() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -149,6 +149,22 @@ function EditSuratKeterangan() {
|
|||||||
const [previewImage2, setPreviewImage2] = useState<string | null>(null);
|
const [previewImage2, setPreviewImage2] = useState<string | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.name?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(formData.deskripsi) &&
|
||||||
|
(previewImage !== null || originalData.imageId !== '') // Either a new file is selected or an existing image exists
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// 🧭 Load Initial Data
|
// 🧭 Load Initial Data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadSurat = async () => {
|
const loadSurat = async () => {
|
||||||
@@ -209,6 +225,21 @@ function EditSuratKeterangan() {
|
|||||||
|
|
||||||
// 💾 Submit Handler
|
// 💾 Submit Handler
|
||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
|
if (!formData.name?.trim()) {
|
||||||
|
toast.error('Nama surat keterangan wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHtmlEmpty(formData.deskripsi)) {
|
||||||
|
toast.error('Konten wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!previewImage && !originalData.imageId) {
|
||||||
|
toast.error('Gambar konten pelayanan wajib dipilih');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
@@ -251,7 +282,7 @@ function EditSuratKeterangan() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
}, [formData, file, file2, router]);
|
}, [formData, file, file2, router, previewImage, originalData.imageId]);
|
||||||
|
|
||||||
// 📝 Form Field Handlers
|
// 📝 Form Field Handlers
|
||||||
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -336,9 +367,11 @@ function EditSuratKeterangan() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
disabled={isSubmitting}
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79,172,254,0.4)',
|
boxShadow: '0 4px 15px rgba(79,172,254,0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -31,6 +31,22 @@ function CreateSuratKeterangan() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
stateSurat.create.form.name?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(stateSurat.create.form.deskripsi) &&
|
||||||
|
previewImage !== null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
stateSurat.create.form = {
|
stateSurat.create.form = {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -43,8 +59,19 @@ function CreateSuratKeterangan() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
if (!stateSurat.create.form.name?.trim()) {
|
||||||
|
toast.error('Nama surat keterangan wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHtmlEmpty(stateSurat.create.form.deskripsi)) {
|
||||||
|
toast.error('Konten wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!previewImage) {
|
if (!previewImage) {
|
||||||
return toast.warn('Pilih file gambar utama terlebih dahulu');
|
toast.error('Gambar konten pelayanan wajib dipilih');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -284,8 +311,11 @@ function CreateSuratKeterangan() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ function EditPelayananTelunjukSakti() {
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.name?.trim() !== '' &&
|
||||||
|
formData.deskripsi?.trim() !== '' &&
|
||||||
|
formData.link?.trim() !== ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
deskripsi: '',
|
deskripsi: '',
|
||||||
@@ -84,6 +93,21 @@ function EditPelayananTelunjukSakti() {
|
|||||||
|
|
||||||
// Submit: update global state hanya saat simpan
|
// Submit: update global state hanya saat simpan
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
if (!formData.name?.trim()) {
|
||||||
|
toast.error('Nama pelayanan wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.deskripsi?.trim()) {
|
||||||
|
toast.error('Judul link wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.link?.trim()) {
|
||||||
|
toast.error('Link wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
stateTelunjukDesa.edit.form = {
|
stateTelunjukDesa.edit.form = {
|
||||||
@@ -165,8 +189,11 @@ function EditPelayananTelunjukSakti() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -23,6 +23,15 @@ function CreatePelayananTelunjukDesa() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
stateTelunjukDesa.create.form.name?.trim() !== '' &&
|
||||||
|
stateTelunjukDesa.create.form.deskripsi?.trim() !== '' &&
|
||||||
|
stateTelunjukDesa.create.form.link?.trim() !== ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
stateTelunjukDesa.create.form = {
|
stateTelunjukDesa.create.form = {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -32,6 +41,21 @@ function CreatePelayananTelunjukDesa() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
if (!stateTelunjukDesa.create.form.name?.trim()) {
|
||||||
|
toast.error('Nama pelayanan wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stateTelunjukDesa.create.form.deskripsi?.trim()) {
|
||||||
|
toast.error('Judul link wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stateTelunjukDesa.create.form.link?.trim()) {
|
||||||
|
toast.error('Link wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
await stateTelunjukDesa.create.create();
|
await stateTelunjukDesa.create.create();
|
||||||
@@ -118,8 +142,11 @@ function CreatePelayananTelunjukDesa() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -33,6 +33,23 @@ function EditPenghargaan() {
|
|||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.name?.trim() !== '' &&
|
||||||
|
formData.juara?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(formData.deskripsi) &&
|
||||||
|
(file !== null || originalData.imageId !== '') // Either a new file is selected or an existing image exists
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const [originalData, setOriginalData] = useState({
|
const [originalData, setOriginalData] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
juara: "",
|
juara: "",
|
||||||
@@ -100,6 +117,26 @@ function EditPenghargaan() {
|
|||||||
|
|
||||||
// Submit
|
// Submit
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
if (!formData.name?.trim()) {
|
||||||
|
toast.error('Nama penghargaan wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.juara?.trim()) {
|
||||||
|
toast.error('Juara wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHtmlEmpty(formData.deskripsi)) {
|
||||||
|
toast.error('Deskripsi wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file && !originalData.imageId) {
|
||||||
|
toast.error('Gambar wajib dipilih');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
// Sync ke global state saat submit
|
// Sync ke global state saat submit
|
||||||
@@ -281,8 +318,11 @@ function EditPenghargaan() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -30,6 +30,23 @@ function CreatePenghargaan() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
statePenghargaan.create.form.name?.trim() !== '' &&
|
||||||
|
statePenghargaan.create.form.juara?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(statePenghargaan.create.form.deskripsi) &&
|
||||||
|
file !== null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
statePenghargaan.create.form = {
|
statePenghargaan.create.form = {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -42,6 +59,26 @@ function CreatePenghargaan() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
if (!statePenghargaan.create.form.name?.trim()) {
|
||||||
|
toast.error('Nama penghargaan wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!statePenghargaan.create.form.juara?.trim()) {
|
||||||
|
toast.error('Juara wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHtmlEmpty(statePenghargaan.create.form.deskripsi)) {
|
||||||
|
toast.error('Deskripsi wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
toast.error('Gambar wajib dipilih');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
if (!file) {
|
if (!file) {
|
||||||
@@ -201,8 +238,11 @@ function CreatePenghargaan() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ function EditKategoriPengumuman() {
|
|||||||
const [formData, setFormData] = useState({ name: '' });
|
const [formData, setFormData] = useState({ name: '' });
|
||||||
const [originalData, setOriginalData] = useState({ name: '' });
|
const [originalData, setOriginalData] = useState({ name: '' });
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.name?.trim() !== ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Load data awal sekali aja
|
// Load data awal sekali aja
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadKategori = async () => {
|
const loadKategori = async () => {
|
||||||
@@ -56,6 +63,11 @@ function EditKategoriPengumuman() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
if (!formData.name?.trim()) {
|
||||||
|
toast.error('Nama kategori pengumuman wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
// Update global state hanya di sini
|
// Update global state hanya di sini
|
||||||
@@ -134,8 +146,11 @@ function EditKategoriPengumuman() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ function CreateKategoriPengumuman() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
createState.create.form.name?.trim() !== ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
createState.create.form = {
|
createState.create.form = {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -29,6 +36,11 @@ function CreateKategoriPengumuman() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
if (!createState.create.form.name?.trim()) {
|
||||||
|
toast.error('Nama kategori pengumuman wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createState.create.create();
|
await createState.create.create();
|
||||||
resetForm();
|
resetForm();
|
||||||
@@ -92,8 +104,11 @@ function CreateKategoriPengumuman() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -36,6 +36,23 @@ function EditPengumuman() {
|
|||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.judul?.trim() !== '' &&
|
||||||
|
formData.deskripsi?.trim() !== '' &&
|
||||||
|
formData.categoryPengumumanId !== '' &&
|
||||||
|
!isHtmlEmpty(formData.content)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const [originalData, setOriginalData] = useState({
|
const [originalData, setOriginalData] = useState({
|
||||||
judul: "",
|
judul: "",
|
||||||
deskripsi: "",
|
deskripsi: "",
|
||||||
@@ -81,6 +98,26 @@ function EditPengumuman() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
if (!formData.judul?.trim()) {
|
||||||
|
toast.error('Judul wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.deskripsi?.trim()) {
|
||||||
|
toast.error('Deskripsi singkat wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.categoryPengumumanId) {
|
||||||
|
toast.error('Kategori wajib dipilih');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHtmlEmpty(formData.content)) {
|
||||||
|
toast.error('Konten lengkap wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
// update global state hanya sekali pas submit
|
// update global state hanya sekali pas submit
|
||||||
@@ -197,8 +234,11 @@ function EditPengumuman() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -27,11 +27,48 @@ function CreatePengumuman() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
pengumumanState.pengumuman.create.form.judul?.trim() !== '' &&
|
||||||
|
pengumumanState.pengumuman.create.form.categoryPengumumanId !== '' &&
|
||||||
|
pengumumanState.pengumuman.create.form.deskripsi?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(pengumumanState.pengumuman.create.form.content)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
useShallowEffect(() => {
|
useShallowEffect(() => {
|
||||||
pengumumanState.category.findMany.load();
|
pengumumanState.category.findMany.load();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
if (!pengumumanState.pengumuman.create.form.judul?.trim()) {
|
||||||
|
toast.error('Judul wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pengumumanState.pengumuman.create.form.categoryPengumumanId) {
|
||||||
|
toast.error('Kategori wajib dipilih');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pengumumanState.pengumuman.create.form.deskripsi?.trim()) {
|
||||||
|
toast.error('Deskripsi singkat wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHtmlEmpty(pengumumanState.pengumuman.create.form.content)) {
|
||||||
|
toast.error('Konten lengkap wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
await pengumumanState.pengumuman.create.create();
|
await pengumumanState.pengumuman.create.create();
|
||||||
@@ -150,8 +187,11 @@ function CreatePengumuman() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ function EditKategoriPotensi() {
|
|||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.nama?.trim() !== ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Load data dari backend -> isi ke formData lokal
|
// Load data dari backend -> isi ke formData lokal
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadKategori = async () => {
|
const loadKategori = async () => {
|
||||||
@@ -73,6 +80,11 @@ function EditKategoriPotensi() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
if (!formData.nama?.trim()) {
|
||||||
|
toast.error('Nama kategori potensi wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
// Update global state hanya pas submit
|
// Update global state hanya pas submit
|
||||||
@@ -141,8 +153,11 @@ function EditKategoriPotensi() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ function CreateKategoriPotensi() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
createState.create.form.nama?.trim() !== ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
createState.create.form = {
|
createState.create.form = {
|
||||||
nama: '',
|
nama: '',
|
||||||
@@ -28,6 +35,11 @@ function CreateKategoriPotensi() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
if (!createState.create.form.nama?.trim()) {
|
||||||
|
alert('Nama kategori potensi wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
await createState.create.create();
|
await createState.create.create();
|
||||||
@@ -91,8 +103,11 @@ function CreateKategoriPotensi() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -42,6 +42,24 @@ function EditPotensi() {
|
|||||||
});
|
});
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.name?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(formData.deskripsi) &&
|
||||||
|
formData.kategoriId !== '' &&
|
||||||
|
(file !== null || originalData.imageId !== '') && // Either a new file is selected or an existing image exists
|
||||||
|
!isHtmlEmpty(formData.content)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const [originalData, setOriginalData] = useState({
|
const [originalData, setOriginalData] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
deskripsi: "",
|
deskripsi: "",
|
||||||
@@ -109,6 +127,31 @@ function EditPotensi() {
|
|||||||
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
if (!formData.name?.trim()) {
|
||||||
|
toast.error('Judul wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHtmlEmpty(formData.deskripsi)) {
|
||||||
|
toast.error('Deskripsi singkat wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.kategoriId) {
|
||||||
|
toast.error('Kategori wajib dipilih');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file && !originalData.imageId) {
|
||||||
|
toast.error('Gambar wajib dipilih');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHtmlEmpty(formData.content)) {
|
||||||
|
toast.error('Konten lengkap wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
let imageId = formData.imageId;
|
let imageId = formData.imageId;
|
||||||
@@ -341,8 +384,11 @@ function EditPotensi() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
|
|||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useProxy } from 'valtio/utils';
|
import { useProxy } from 'valtio/utils';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
export default function DetailPotensi() {
|
export default function DetailPotensi() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -77,7 +78,17 @@ export default function DetailPotensi() {
|
|||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz="lg" fw="bold">Deskripsi</Text>
|
<Text fz="lg" fw="bold">Deskripsi</Text>
|
||||||
<Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }}></Text>
|
<Text
|
||||||
|
fz="md"
|
||||||
|
c="dimmed"
|
||||||
|
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: DOMPurify.sanitize(data.deskripsi || '-', {
|
||||||
|
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'ul', 'ol', 'li'],
|
||||||
|
ALLOWED_ATTR: []
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
></Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
@@ -102,7 +113,12 @@ export default function DetailPotensi() {
|
|||||||
<Text
|
<Text
|
||||||
fz="md"
|
fz="md"
|
||||||
c="dimmed"
|
c="dimmed"
|
||||||
dangerouslySetInnerHTML={{ __html: data.content || '-' }}
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: DOMPurify.sanitize(data.content || '-', {
|
||||||
|
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'ul', 'ol', 'li'],
|
||||||
|
ALLOWED_ATTR: []
|
||||||
|
})
|
||||||
|
}}
|
||||||
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
|
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -32,11 +32,54 @@ function CreatePotensi() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
potensiState.create.form.name?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(potensiState.create.form.deskripsi) &&
|
||||||
|
potensiState.create.form.kategoriId !== '' &&
|
||||||
|
file !== null &&
|
||||||
|
!isHtmlEmpty(potensiState.create.form.content)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
potensiDesaState.kategoriPotensi.findMany.load();
|
potensiDesaState.kategoriPotensi.findMany.load();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
if (!potensiState.create.form.name?.trim()) {
|
||||||
|
toast.error('Judul wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHtmlEmpty(potensiState.create.form.deskripsi)) {
|
||||||
|
toast.error('Deskripsi singkat wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!potensiState.create.form.kategoriId) {
|
||||||
|
toast.error('Kategori wajib dipilih');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
toast.error('Gambar wajib dipilih');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHtmlEmpty(potensiState.create.form.content)) {
|
||||||
|
toast.error('Konten lengkap wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
if (!file) return toast.warn('Pilih file gambar terlebih dahulu');
|
if (!file) return toast.warn('Pilih file gambar terlebih dahulu');
|
||||||
@@ -266,8 +309,11 @@ function CreatePotensi() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { useProxy } from 'valtio/utils';
|
|||||||
import HeaderSearch from '../../../_com/header';
|
import HeaderSearch from '../../../_com/header';
|
||||||
import potensiDesaState from '../../../_state/desa/potensi';
|
import potensiDesaState from '../../../_state/desa/potensi';
|
||||||
import { useDebouncedValue } from '@mantine/hooks';
|
import { useDebouncedValue } from '@mantine/hooks';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
function Potensi() {
|
function Potensi() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@@ -137,7 +138,12 @@ function ListPotensi({ search }: { search: string }) {
|
|||||||
fz="sm"
|
fz="sm"
|
||||||
lh={1.5}
|
lh={1.5}
|
||||||
lineClamp={2}
|
lineClamp={2}
|
||||||
dangerouslySetInnerHTML={{ __html: item.deskripsi }}
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: DOMPurify.sanitize(item.deskripsi, {
|
||||||
|
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'ul', 'ol', 'li'],
|
||||||
|
ALLOWED_ATTR: []
|
||||||
|
})
|
||||||
|
}}
|
||||||
style={{ wordBreak: 'break-word' }}
|
style={{ wordBreak: 'break-word' }}
|
||||||
/>
|
/>
|
||||||
</TableTd>
|
</TableTd>
|
||||||
@@ -199,7 +205,12 @@ function ListPotensi({ search }: { search: string }) {
|
|||||||
<Text
|
<Text
|
||||||
fz="sm"
|
fz="sm"
|
||||||
lh={1.5}
|
lh={1.5}
|
||||||
dangerouslySetInnerHTML={{ __html: item.deskripsi }}
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: DOMPurify.sanitize(item.deskripsi, {
|
||||||
|
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'ul', 'ol', 'li'],
|
||||||
|
ALLOWED_ATTR: []
|
||||||
|
})
|
||||||
|
}}
|
||||||
style={{ wordBreak: 'break-word' }}
|
style={{ wordBreak: 'break-word' }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -83,6 +83,21 @@ function Page() {
|
|||||||
};
|
};
|
||||||
}, [params?.id, router]);
|
}, [params?.id, router]);
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.judul?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(formData.deskripsi)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// 🔁 Reset form
|
// 🔁 Reset form
|
||||||
const handleResetForm = () => {
|
const handleResetForm = () => {
|
||||||
setFormData(originalData);
|
setFormData(originalData);
|
||||||
@@ -96,6 +111,11 @@ function Page() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isHtmlEmpty(formData.deskripsi)) {
|
||||||
|
toast.error('Deskripsi wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const state = stateProfileDesa.lambangDesa;
|
const state = stateProfileDesa.lambangDesa;
|
||||||
@@ -224,14 +244,16 @@ function Page() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
loading={isSubmitting}
|
|
||||||
>
|
>
|
||||||
Simpan
|
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -34,6 +34,21 @@ function Page() {
|
|||||||
images: [] as Array<{ label: string; imageId: string }>
|
images: [] as Array<{ label: string; imageId: string }>
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.judul?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(formData.deskripsi)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Load data
|
// Load data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
@@ -122,6 +137,12 @@ function Page() {
|
|||||||
toast.error("Judul wajib diisi");
|
toast.error("Judul wajib diisi");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isHtmlEmpty(formData.deskripsi)) {
|
||||||
|
toast.error("Deskripsi wajib diisi");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
const uploadedImages = [];
|
const uploadedImages = [];
|
||||||
@@ -315,8 +336,11 @@ function Page() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -99,6 +99,21 @@ function Page() {
|
|||||||
toast.info('Form dikembalikan ke data awal');
|
toast.info('Form dikembalikan ke data awal');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.judul?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(formData.deskripsi)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// 💾 Submit Handler
|
// 💾 Submit Handler
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
// Validation
|
// Validation
|
||||||
@@ -107,6 +122,11 @@ function Page() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isHtmlEmpty(formData.deskripsi)) {
|
||||||
|
toast.error('Deskripsi wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -260,8 +280,11 @@ function Page() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -81,6 +81,21 @@ function Page() {
|
|||||||
};
|
};
|
||||||
}, [params?.id, router]);
|
}, [params?.id, router]);
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
!isHtmlEmpty(formData.visi) &&
|
||||||
|
!isHtmlEmpty(formData.misi)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// 🔄 Reset Form
|
// 🔄 Reset Form
|
||||||
const handleResetForm = () => {
|
const handleResetForm = () => {
|
||||||
setFormData(originalData);
|
setFormData(originalData);
|
||||||
@@ -89,11 +104,16 @@ function Page() {
|
|||||||
|
|
||||||
// 💾 Submit
|
// 💾 Submit
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!formData.visi.trim()) {
|
if (isHtmlEmpty(formData.visi)) {
|
||||||
toast.error('Visi wajib diisi');
|
toast.error('Visi wajib diisi');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isHtmlEmpty(formData.misi)) {
|
||||||
|
toast.error('Misi wajib diisi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const originalState = stateProfileDesa.visiMisiDesa;
|
const originalState = stateProfileDesa.visiMisiDesa;
|
||||||
@@ -227,14 +247,16 @@ function Page() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
loading={isSubmitting}
|
|
||||||
>
|
>
|
||||||
Simpan
|
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -51,6 +51,17 @@ function EditAPBDesa() {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.tahun?.trim() !== '' &&
|
||||||
|
Number(formData.tahun) > 0 &&
|
||||||
|
formData.pendapatanIds.length > 0 &&
|
||||||
|
formData.belanjaIds.length > 0 &&
|
||||||
|
formData.pembiayaanIds.length > 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ==================== LOAD DATA ====================
|
// ==================== LOAD DATA ====================
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadAPBdesa = async () => {
|
const loadAPBdesa = async () => {
|
||||||
@@ -213,8 +224,11 @@ function EditAPBDesa() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -27,6 +27,17 @@ function CreateAPBDesa() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
apbDesaState.create.form.tahun !== null &&
|
||||||
|
apbDesaState.create.form.tahun > 0 &&
|
||||||
|
apbDesaState.create.form.pendapatanIds.length > 0 &&
|
||||||
|
apbDesaState.create.form.belanjaIds.length > 0 &&
|
||||||
|
apbDesaState.create.form.pembiayaanIds.length > 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
apbDesaState.create.form = {
|
apbDesaState.create.form = {
|
||||||
tahun: 0,
|
tahun: 0,
|
||||||
@@ -121,8 +132,11 @@ function CreateAPBDesa() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -35,6 +35,15 @@ function EditBelanja() {
|
|||||||
value: '',
|
value: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.name?.trim() !== '' &&
|
||||||
|
formData.value !== '' &&
|
||||||
|
Number(formData.value) > 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// format angka ke rupiah
|
// format angka ke rupiah
|
||||||
const formatRupiah = (value: number | string) => {
|
const formatRupiah = (value: number | string) => {
|
||||||
const number =
|
const number =
|
||||||
@@ -172,8 +181,11 @@ function EditBelanja() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ function CreateBelanja() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
belanjaState.create.form.name?.trim() !== '' &&
|
||||||
|
belanjaState.create.form.value !== null &&
|
||||||
|
belanjaState.create.form.value > 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const formatRupiah = (value: number | string) => {
|
const formatRupiah = (value: number | string) => {
|
||||||
const number =
|
const number =
|
||||||
typeof value === 'number' ? value : Number(value.replace(/\D/g, ''));
|
typeof value === 'number' ? value : Number(value.replace(/\D/g, ''));
|
||||||
@@ -126,8 +135,11 @@ function CreateBelanja() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ function EditPembiayaan() {
|
|||||||
value: '',
|
value: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.name?.trim() !== '' &&
|
||||||
|
formData.value !== '' &&
|
||||||
|
Number(formData.value) > 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const formatRupiah = (value: number | string) => {
|
const formatRupiah = (value: number | string) => {
|
||||||
const number =
|
const number =
|
||||||
typeof value === 'number'
|
typeof value === 'number'
|
||||||
@@ -169,8 +178,11 @@ function EditPembiayaan() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -23,6 +23,15 @@ function CreatePembiayaan() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
pembiayaanState.create.form.name?.trim() !== '' &&
|
||||||
|
pembiayaanState.create.form.value !== null &&
|
||||||
|
pembiayaanState.create.form.value > 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const formatRupiah = (value: number | string) => {
|
const formatRupiah = (value: number | string) => {
|
||||||
const number =
|
const number =
|
||||||
typeof value === 'number' ? value : Number(value.replace(/\D/g, ''));
|
typeof value === 'number' ? value : Number(value.replace(/\D/g, ''));
|
||||||
@@ -127,8 +136,11 @@ function CreatePembiayaan() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ function EditPendapatan() {
|
|||||||
value: '',
|
value: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.name?.trim() !== '' &&
|
||||||
|
formData.value !== '' &&
|
||||||
|
Number(formData.value) > 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// helper format
|
// helper format
|
||||||
const formatRupiah = (value: number | string) => {
|
const formatRupiah = (value: number | string) => {
|
||||||
const number = typeof value === 'number'
|
const number = typeof value === 'number'
|
||||||
@@ -176,8 +185,11 @@ function EditPendapatan() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ function CreatePendapatan() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
pendapatanState.create.form.name?.trim() !== '' &&
|
||||||
|
pendapatanState.create.form.value !== null &&
|
||||||
|
pendapatanState.create.form.value > 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const formatRupiah = (value: number | string) => {
|
const formatRupiah = (value: number | string) => {
|
||||||
const number = typeof value === 'number' ? value : Number(value.replace(/\D/g, ''));
|
const number = typeof value === 'number' ? value : Number(value.replace(/\D/g, ''));
|
||||||
return new Intl.NumberFormat('id-ID', {
|
return new Intl.NumberFormat('id-ID', {
|
||||||
@@ -122,8 +131,11 @@ function CreatePendapatan() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ export default function EditPegawaiBumDes() {
|
|||||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.namaLengkap?.trim() !== '' &&
|
||||||
|
formData.posisiId?.trim() !== ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Format date for <input type="date">
|
// Format date for <input type="date">
|
||||||
const formatDateForInput = (dateString: string) => {
|
const formatDateForInput = (dateString: string) => {
|
||||||
if (!dateString) return '';
|
if (!dateString) return '';
|
||||||
@@ -325,8 +333,11 @@ export default function EditPegawaiBumDes() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -23,6 +23,15 @@ function CreatePegawaiBumDes() {
|
|||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
stateOrganisasi.create.form.namaLengkap?.trim() !== '' &&
|
||||||
|
stateOrganisasi.create.form.posisiId?.trim() !== '' &&
|
||||||
|
file !== null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
stateOrganisasi.create.form = {
|
stateOrganisasi.create.form = {
|
||||||
namaLengkap: "",
|
namaLengkap: "",
|
||||||
@@ -284,8 +293,11 @@ function CreatePegawaiBumDes() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -31,6 +31,23 @@ function EditPosisiOrganisasiBumDes() {
|
|||||||
hierarki: 0,
|
hierarki: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.nama?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(formData.deskripsi) &&
|
||||||
|
formData.hierarki !== null &&
|
||||||
|
formData.hierarki >= 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Fungsi generik untuk update formData
|
// Fungsi generik untuk update formData
|
||||||
const handleChange = (field: keyof typeof formData, value: any) => {
|
const handleChange = (field: keyof typeof formData, value: any) => {
|
||||||
setFormData(prev => ({ ...prev, [field]: value }));
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
@@ -173,8 +190,11 @@ function EditPosisiOrganisasiBumDes() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -15,6 +15,23 @@ function CreatePosisiOrganisasiBumDes() {
|
|||||||
const stateOrganisasi = useProxy(stateStrukturBumDes.posisiOrganisasi);
|
const stateOrganisasi = useProxy(stateStrukturBumDes.posisiOrganisasi);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
stateOrganisasi.create.form.nama?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(stateOrganisasi.create.form.deskripsi) &&
|
||||||
|
stateOrganisasi.create.form.hierarki !== null &&
|
||||||
|
stateOrganisasi.create.form.hierarki >= 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
stateOrganisasi.findMany.load();
|
stateOrganisasi.findMany.load();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -115,8 +132,11 @@ function CreatePosisiOrganisasiBumDes() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -41,6 +41,17 @@ export default function EditDemografiPekerjaan() {
|
|||||||
perempuan: 0,
|
perempuan: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.pekerjaan?.trim() !== '' &&
|
||||||
|
formData.lakiLaki !== null &&
|
||||||
|
formData.lakiLaki >= 0 &&
|
||||||
|
formData.perempuan !== null &&
|
||||||
|
formData.perempuan >= 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ✅ Load data hanya sekali di awal (tidak reset form)
|
// ✅ Load data hanya sekali di awal (tidak reset form)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -186,8 +197,11 @@ export default function EditDemografiPekerjaan() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -26,6 +26,17 @@ function CreateDemografiPekerjaan() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
stateDemografi.create.form.pekerjaan?.trim() !== '' &&
|
||||||
|
stateDemografi.create.form.lakiLaki !== null &&
|
||||||
|
stateDemografi.create.form.lakiLaki >= 0 &&
|
||||||
|
stateDemografi.create.form.perempuan !== null &&
|
||||||
|
stateDemografi.create.form.perempuan >= 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
stateDemografi.create.form = {
|
stateDemografi.create.form = {
|
||||||
pekerjaan: '',
|
pekerjaan: '',
|
||||||
@@ -128,8 +139,11 @@ function CreateDemografiPekerjaan() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ function EditJumlahPendudukMiskin() {
|
|||||||
totalPoorPopulation: 0,
|
totalPoorPopulation: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.year !== null &&
|
||||||
|
formData.year > 0 &&
|
||||||
|
formData.totalPoorPopulation !== null &&
|
||||||
|
formData.totalPoorPopulation >= 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// 🔹 Load data awal dari backend
|
// 🔹 Load data awal dari backend
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -160,8 +170,11 @@ function EditJumlahPendudukMiskin() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ export default function CreateJumlahPendudukMiskin() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
stateJPM.create.form.year !== null &&
|
||||||
|
stateJPM.create.form.year > 0 &&
|
||||||
|
stateJPM.create.form.totalPoorPopulation !== null &&
|
||||||
|
stateJPM.create.form.totalPoorPopulation >= 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
stateJPM.create.form = {
|
stateJPM.create.form = {
|
||||||
year: new Date().getFullYear(),
|
year: new Date().getFullYear(),
|
||||||
@@ -105,8 +115,11 @@ export default function CreateJumlahPendudukMiskin() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -33,6 +33,17 @@ function EditGrafikBerdasarkanPendidikan() {
|
|||||||
S1: '',
|
S1: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.SD?.trim() !== '' &&
|
||||||
|
formData.SMP?.trim() !== '' &&
|
||||||
|
formData.SMA?.trim() !== '' &&
|
||||||
|
formData.D3?.trim() !== '' &&
|
||||||
|
formData.S1?.trim() !== ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) {
|
if (id) {
|
||||||
stategrafik.findUnique.load(id).then(() => {
|
stategrafik.findUnique.load(id).then(() => {
|
||||||
@@ -174,8 +185,11 @@ function EditGrafikBerdasarkanPendidikan() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -16,6 +16,17 @@ function CreateGrafikBerdasarkanPendidikan() {
|
|||||||
const [donutData, setDonutData] = useState<any[]>([]);
|
const [donutData, setDonutData] = useState<any[]>([]);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
stategrafik.create.form.SD?.trim() !== '' &&
|
||||||
|
stategrafik.create.form.SMP?.trim() !== '' &&
|
||||||
|
stategrafik.create.form.SMA?.trim() !== '' &&
|
||||||
|
stategrafik.create.form.D3?.trim() !== '' &&
|
||||||
|
stategrafik.create.form.S1?.trim() !== ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
stategrafik.create.form = {
|
stategrafik.create.form = {
|
||||||
...stategrafik.create.form,
|
...stategrafik.create.form,
|
||||||
@@ -127,8 +138,11 @@ function CreateGrafikBerdasarkanPendidikan() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -43,6 +43,16 @@ function EditGrafikBerdasarkanUsiaKerjaYangMenganggur() {
|
|||||||
usia46_keatas: '',
|
usia46_keatas: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.usia18_25?.trim() !== '' &&
|
||||||
|
formData.usia26_35?.trim() !== '' &&
|
||||||
|
formData.usia36_45?.trim() !== '' &&
|
||||||
|
formData.usia46_keatas?.trim() !== ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// load data dari global state -> masukin ke local state
|
// load data dari global state -> masukin ke local state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) {
|
if (id) {
|
||||||
@@ -179,8 +189,11 @@ function EditGrafikBerdasarkanUsiaKerjaYangMenganggur() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ function CreateGrafikBerdasarkanUsiaKerjaYangMenganggur() {
|
|||||||
const [donutData, setDonutData] = useState<any[]>([]);
|
const [donutData, setDonutData] = useState<any[]>([]);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
stategrafik.create.form.usia18_25?.trim() !== '' &&
|
||||||
|
stategrafik.create.form.usia26_35?.trim() !== '' &&
|
||||||
|
stategrafik.create.form.usia36_45?.trim() !== '' &&
|
||||||
|
stategrafik.create.form.usia46_keatas?.trim() !== ''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
stategrafik.create.form = {
|
stategrafik.create.form = {
|
||||||
...stategrafik.create.form,
|
...stategrafik.create.form,
|
||||||
@@ -120,8 +130,11 @@ function CreateGrafikBerdasarkanUsiaKerjaYangMenganggur() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -53,6 +53,19 @@ function EditDetailDataPengangguran() {
|
|||||||
percentageChange: 0,
|
percentageChange: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.month?.trim() !== '' &&
|
||||||
|
formData.year !== null &&
|
||||||
|
formData.year > 0 &&
|
||||||
|
formData.educatedUnemployment !== null &&
|
||||||
|
formData.educatedUnemployment >= 0 &&
|
||||||
|
formData.uneducatedUnemployment !== null &&
|
||||||
|
formData.uneducatedUnemployment >= 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// --- hitung total + persentase perubahan
|
// --- hitung total + persentase perubahan
|
||||||
const calculateTotalAndChange = useCallback(
|
const calculateTotalAndChange = useCallback(
|
||||||
async (data: typeof formData) => {
|
async (data: typeof formData) => {
|
||||||
@@ -255,8 +268,11 @@ function EditDetailDataPengangguran() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -27,6 +27,19 @@ function CreateJumlahPengangguran() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
stateDetail.create.form.month?.trim() !== '' &&
|
||||||
|
stateDetail.create.form.year !== null &&
|
||||||
|
stateDetail.create.form.year > 0 &&
|
||||||
|
stateDetail.create.form.educatedUnemployment !== null &&
|
||||||
|
stateDetail.create.form.educatedUnemployment >= 0 &&
|
||||||
|
stateDetail.create.form.uneducatedUnemployment !== null &&
|
||||||
|
stateDetail.create.form.uneducatedUnemployment >= 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const monthOptions = [
|
const monthOptions = [
|
||||||
'Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun',
|
'Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun',
|
||||||
'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'
|
'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'
|
||||||
@@ -204,8 +217,11 @@ function CreateJumlahPengangguran() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -48,6 +48,27 @@ function EditLowonganKerja() {
|
|||||||
notelp: '',
|
notelp: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.posisi?.trim() !== '' &&
|
||||||
|
formData.namaPerusahaan?.trim() !== '' &&
|
||||||
|
formData.notelp?.trim() !== '' &&
|
||||||
|
formData.lokasi?.trim() !== '' &&
|
||||||
|
formData.tipePekerjaan?.trim() !== '' &&
|
||||||
|
formData.gaji?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(formData.deskripsi) &&
|
||||||
|
!isHtmlEmpty(formData.kualifikasi)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// load data sekali aja ketika mount / id berubah
|
// load data sekali aja ketika mount / id berubah
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadLowongan = async () => {
|
const loadLowongan = async () => {
|
||||||
@@ -229,8 +250,11 @@ function EditLowonganKerja() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -24,6 +24,27 @@ function CreateLowonganKerja() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
lowonganState.create.form.posisi?.trim() !== '' &&
|
||||||
|
lowonganState.create.form.namaPerusahaan?.trim() !== '' &&
|
||||||
|
lowonganState.create.form.notelp?.trim() !== '' &&
|
||||||
|
lowonganState.create.form.lokasi?.trim() !== '' &&
|
||||||
|
lowonganState.create.form.tipePekerjaan?.trim() !== '' &&
|
||||||
|
lowonganState.create.form.gaji?.trim() !== '' &&
|
||||||
|
!isHtmlEmpty(lowonganState.create.form.deskripsi) &&
|
||||||
|
!isHtmlEmpty(lowonganState.create.form.kualifikasi)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
lowonganState.create.form = {
|
lowonganState.create.form = {
|
||||||
posisi: '',
|
posisi: '',
|
||||||
@@ -175,8 +196,11 @@ function CreateLowonganKerja() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ function EditKategoriProduk() {
|
|||||||
const [formData, setFormData] = useState({ nama: '' });
|
const [formData, setFormData] = useState({ nama: '' });
|
||||||
const [originalData, setOriginalData] = useState({ nama: '' });
|
const [originalData, setOriginalData] = useState({ nama: '' });
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return formData.nama?.trim() !== '';
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadKategoriProduk = async () => {
|
const loadKategoriProduk = async () => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -146,8 +151,11 @@ function EditKategoriProduk() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ function CreateKategoriProduk() {
|
|||||||
const statePasar = useProxy(pasarDesaState.kategoriProduk);
|
const statePasar = useProxy(pasarDesaState.kategoriProduk);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return statePasar.create.form.nama?.trim() !== '';
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
statePasar.findMany.load();
|
statePasar.findMany.load();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -101,8 +106,11 @@ function CreateKategoriProduk() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -68,6 +68,23 @@ function EditPasarDesa() {
|
|||||||
deskripsi: ''
|
deskripsi: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.nama?.trim() !== '' &&
|
||||||
|
formData.harga !== null &&
|
||||||
|
formData.harga > 0 &&
|
||||||
|
!isHtmlEmpty(formData.deskripsi)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// load data awal
|
// load data awal
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
pasarState.kategoriProduk.findManyAll.load();
|
pasarState.kategoriProduk.findManyAll.load();
|
||||||
@@ -352,8 +369,11 @@ function EditPasarDesa() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -32,6 +32,24 @@ export default function CreatePasarDesa() {
|
|||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Helper function to check if HTML content is empty
|
||||||
|
const isHtmlEmpty = (html: string) => {
|
||||||
|
// Remove all HTML tags and check if there's any text content
|
||||||
|
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||||
|
return textContent === '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if form is valid
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
statePasar.pasarDesa.create.form.nama?.trim() !== '' &&
|
||||||
|
statePasar.pasarDesa.create.form.harga !== null &&
|
||||||
|
statePasar.pasarDesa.create.form.harga > 0 &&
|
||||||
|
!isHtmlEmpty(statePasar.pasarDesa.create.form.deskripsi) &&
|
||||||
|
file !== null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
statePasar.kategoriProduk.findManyAll.load();
|
statePasar.kategoriProduk.findManyAll.load();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -265,8 +283,11 @@ export default function CreatePasarDesa() {
|
|||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
|
disabled={!isFormValid() || isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
background: !isFormValid() || isSubmitting
|
||||||
|
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||||
|
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||||
}}
|
}}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user