refactor(ekonomi): consolidate Pasar Desa into UMKM module

- Remove "Pasar Desa" as a separate entity; products are now strictly linked to UMKM.
- Delete redundant Pasar Desa API endpoints and state management.
- Update Admin UI: remove "Pasar Desa" menu and unified product management under UMKM.
- Update Public UI: replace "Pasar Desa" with "UMKM" in navbar and unified hub at /darmasaba/ekonomi/umkm.
- Implement mandatory umkmId in PasarDesa model and update seeders accordingly.
- Fix UI bugs, missing imports, and invalid API filters for mandatory umkmId.
- Increment version to 0.1.18.
This commit is contained in:
2026-04-21 17:52:08 +08:00
parent e286cb4f2b
commit 1a48c15c87
45 changed files with 341 additions and 3409 deletions

View File

@@ -0,0 +1,5 @@
# Memory Context from Past Sessions
*No context yet. Complete your first session and context will appear here.*
Use claude-mem's MCP search tools for manual memory queries.

View File

@@ -0,0 +1,24 @@
# Plan: Refactor UMKM and Pasar Desa (Consolidation)
## Objective
Consolidate "Pasar Desa" into the UMKM module. Pasar Desa is no longer a separate entity; it is now strictly a collection of products belonging to UMKM entities.
## Steps:
1. **Cleanup API**: Remove `PasarDesa` and `KategoriProduk` (from `pasar-desa` folder) imports from `src/app/api/[[...slugs]]/_lib/ekonomi/index.ts`.
2. **Admin UI**:
- Remove "Pasar Desa" menu from `src/app/admin/_com/list_PageAdmin.tsx`.
- Ensure "UMKM" menu handles all product management.
3. **Public UI**:
- Remove "Pasar Desa" from `src/con/navbar-list-menu.ts`.
- Refactor `src/app/darmasaba/(pages)/ekonomi/pasar-desa/page.tsx` to remove the "Produk Pasar Desa" tab.
- Rename the page or adjust its purpose to be the unified UMKM/Product hub.
4. **Prisma Schema**:
- Ensure `umkmId` is mandatory in `PasarDesa` model (already seems to be).
- (Optional) Rename `PasarDesa` to `ProdukUmkm` if requested, but user said it's optional. For now, keep it as `PasarDesa` to minimize breaking changes.
5. **Build & Verify**: Run `bun run build` and check for any broken references.
## Verification:
- No "Pasar Desa" menu in Admin.
- No "Pasar Desa" menu in Public Navbar.
- Public page `/darmasaba/ekonomi/pasar-desa` (or new path) shows UMKM products only.
- Successful build.

View File

@@ -0,0 +1,8 @@
# Task: Refactor UMKM and Pasar Desa (Consolidation)
- [ ] Cleanup API imports in `src/app/api/[[...slugs]]/_lib/ekonomi/index.ts` <!-- id: 0 -->
- [ ] Remove "Pasar Desa" menu in `src/app/admin/_com/list_PageAdmin.tsx` <!-- id: 1 -->
- [ ] Remove "Pasar Desa" from public navbar in `src/con/navbar-list-menu.ts` <!-- id: 2 -->
- [ ] Refactor public page `src/app/darmasaba/(pages)/ekonomi/pasar-desa/page.tsx` <!-- id: 3 -->
- [ ] Run build and fix errors <!-- id: 4 -->
- [ ] Update version and commit <!-- id: 5 -->

View File

@@ -0,0 +1,34 @@
# Summary: Refactor UMKM and Pasar Desa (Consolidation)
## Objective
Successfully consolidated "Pasar Desa" into the UMKM module. Pasar Desa is now strictly a part of the UMKM ecosystem, where every product must belong to an UMKM entity.
## Changes Made:
1. **Backend & API**:
- Removed redundant `pasar-desa` API endpoints from `src/app/api/[[...slugs]]/_lib/ekonomi/index.ts`.
- Removed invalid `not: null` filters for `umkmId` in UMKM dashboard and product findMany APIs (since `umkmId` is now mandatory).
- Updated `umkmState` to include `findUnique` for products.
2. **Admin UI**:
- Removed "Pasar Desa" menu items from `src/app/admin/_com/list_PageAdmin.tsx` for all roles.
- Cleaned up unused state management for `pasar-desa`.
3. **Public UI**:
- Replaced "Pasar Desa" with "UMKM" in the public navbar (`src/con/navbar-list-menu.ts`).
- Unified the public hub at `/darmasaba/ekonomi/umkm`.
- Refactored the hub page to remove the "Produk Pasar Desa" tab and rename other tabs to "Katalog Produk" and "Direktori Bisnis".
- Updated product detail routing to `/darmasaba/ekonomi/umkm/produk/[id]`.
- Updated UMKM profile routing to `/darmasaba/ekonomi/umkm/[id]`.
4. **Database & Seeding**:
- Created a new UMKM seeder (`prisma/_seeder_list/ekonomi/seed_umkm.ts`).
- Updated `seedPasarDesa` to link products to UMKM entities, satisfying the mandatory `umkmId` constraint.
- Integrated `seedUmkm` into the main `seed.ts`.
5. **Code Cleanup**:
- Fixed missing imports (e.g., `IconUser`).
- Removed unused imports across several files.
- Fixed copy-pasted toast messages in unrelated modules.
## Verification**:
- Build successful (`bun run build`).
- No "Pasar Desa" menu in Admin.
- "UMKM" menu in Public Navbar points to unified hub.
- Unified hub shows products linked to UMKM.
- Product detail pages correctly show seller information.

View File

@@ -1,6 +1,6 @@
{
"name": "desa-darmasaba",
"version": "0.1.17",
"version": "0.1.18",
"private": true,
"scripts": {
"dev": "next dev",

View File

@@ -25,8 +25,11 @@ export async function seedPasarDesa() {
console.log("🔄 Seeding Pasar Desa...");
let i = 1;
for (const p of pasarDesa) {
let imageId: string | null = null;
const umkmId = `umkm-${i}`; // Map to umkm-1, umkm-2, etc.
i = (i % 4) + 1;
if (p.imageName) {
const image = await prisma.fileStorage.findUnique({
@@ -54,6 +57,7 @@ export async function seedPasarDesa() {
kontak: p.kontak,
imageId,
kategoriProdukId: p.kategoriProdukId,
umkmId: umkmId,
},
create: {
id: p.id,
@@ -65,6 +69,7 @@ export async function seedPasarDesa() {
kontak: p.kontak,
imageId,
kategoriProdukId: p.kategoriProdukId,
umkmId: umkmId,
},
});

View File

@@ -0,0 +1,67 @@
import prisma from "@/lib/prisma";
export const umkmData = [
{
id: "umkm-1",
nama: "Warung Pasar Darmasaba",
pemilik: "Pak Made",
deskripsi: "Warung tradisional kebutuhan pokok",
alamat: "Pasar Desa Darmasaba",
kontak: "081234567890",
kategoriId: "5c06chf7-123f-7igd-0663-5e9h76e55060"
},
{
id: "umkm-2",
nama: "Jajanan Pasar Bu Made",
pemilik: "Bu Made",
deskripsi: "Spesialis jajanan tradisional Bali",
alamat: "Pasar Desa Darmasaba",
kontak: "082145678901",
kategoriId: "4b95bge6-012e-5ged-9552-4d8g65d44959"
},
{
id: "umkm-3",
nama: "Sayur Segar Pak Wayan",
pemilik: "Pak Wayan",
deskripsi: "Sayuran lokal segar setiap hari",
alamat: "Pasar Desa Darmasaba",
kontak: "087865432109",
kategoriId: "5c06chf7-123f-8jhe-0663-5e9h76e55060"
},
{
id: "umkm-4",
nama: "Ayam & Daging Segar Darmasaba",
pemilik: "Pak Ketut",
deskripsi: "Daging ayam dan sapi segar",
alamat: "Pasar Desa Darmasaba",
kontak: "081998877665",
kategoriId: "5c06chf7-123f-9kif-0663-5e9h76e55060"
}
];
export async function seedUmkm() {
console.log("🔄 Seeding UMKM...");
for (const u of umkmData) {
await prisma.umkm.upsert({
where: { id: u.id },
update: {
nama: u.nama,
pemilik: u.pemilik,
deskripsi: u.deskripsi,
alamat: u.alamat,
kontak: u.kontak,
kategoriId: u.kategoriId,
},
create: {
id: u.id,
nama: u.nama,
pemilik: u.pemilik,
deskripsi: u.deskripsi,
alamat: u.alamat,
kontak: u.kontak,
kategoriId: u.kategoriId,
},
});
}
console.log("✅ UMKM seeded successfully");
}

View File

@@ -1434,8 +1434,8 @@ model PasarDesa {
// Data Stok & UMKM
stok Int @default(0)
umkm Umkm? @relation(fields: [umkmId], references: [id])
umkmId String?
umkm Umkm @relation(fields: [umkmId], references: [id])
umkmId String
// Relasi Penjualan
penjualan PenjualanProduk[]

View File

@@ -23,6 +23,7 @@ import { seedPendudukUsiaKerjaYangMenganggur } from "./_seeder_list/ekonomi/seed
import { seedProgramKemiskinan } from "./_seeder_list/ekonomi/seed_program_kemiskinan";
import { seedSektorUnggulanDesa } from "./_seeder_list/ekonomi/seed_sektor_unggulan_desa";
import { seedStrukturBumdes } from "./_seeder_list/ekonomi/seed_struktur_bumdes";
import { seedUmkm } from "./_seeder_list/ekonomi/seed_umkm";
import { seedAjukan } from "./_seeder_list/inovasi/seed_ajukan";
import { seedDesaDigital } from "./_seeder_list/inovasi/seed_desa_digital";
import { seedInfoTeknologi } from "./_seeder_list/inovasi/seed_info_teknologi";
@@ -274,6 +275,9 @@ import seedAssets from "./seed_assets";
await seedKeamananLingkungan();
// // ====================== MENU EKONOMI ========================
// // ==================== SUBMENU UMKM ==========================
await seedUmkm();
// // ==================== SUBMENU PASAR DESA ====================
await seedPasarDesa();

View File

@@ -1,563 +0,0 @@
/* 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";
const templatePasarDesaForm = z.object({
nama: z.string().min(1, "Nama minimal 1 karakter"),
harga: z.number().min(1, "Harga minimal 1"),
alamatUsaha: z.string().min(1, "Alamat minimal 1 karakter"),
imageId: z.string().min(1, "Gambar wajib dipilih"),
rating: z.number().min(1, "Rating minimal 1"),
kategoriId: z.array(z.string()).min(1, "Minimal pilih satu kategori"),
kontak: z.string().min(1, "Kontak wajib diisi"),
deskripsi: z.string().min(1, "Deskripsi wajib diisi"),
});
const defaultPasarDesaForm = {
nama: "",
harga: 0,
alamatUsaha: "",
imageId: "",
rating: 0,
kategoriId: [] as string[],
kontak: "",
deskripsi: ""
};
const pasarDesa = proxy({
create: {
form: { ...defaultPasarDesaForm },
loading: false,
async create() {
const cek = templatePasarDesaForm.safeParse(pasarDesa.create.form);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
return toast.error(err);
}
try {
pasarDesa.create.loading = true;
const res = await ApiFetch.api.ekonomi.pasardesa["create"].post(
pasarDesa.create.form
);
if (res.status === 200) {
pasarDesa.findMany.load();
return toast.success("Data berhasil ditambahkan");
}
return toast.error("Gagal menambahkan data");
} catch (error) {
console.log(error);
toast.error("Gagal menambahkan data");
} finally {
pasarDesa.create.loading = false;
}
},
},
findMany: {
data: null as
| Prisma.PasarDesaGetPayload<{
include: {
image: true;
KategoriToPasar: {
include: {
kategori: true;
};
};
};
}>[]
| null,
page: 1,
totalPages: 1,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "", categoryId?: string) => {
pasarDesa.findMany.loading = true;
pasarDesa.findMany.page = page;
pasarDesa.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
if (categoryId) query.categoryId = categoryId;
const res = await ApiFetch.api.ekonomi.pasardesa["find-many"].get({ query });
if (res.status === 200 && res.data?.success) {
pasarDesa.findMany.data = res.data.data ?? [];
pasarDesa.findMany.totalPages = res.data.totalPages ?? 1;
} else {
pasarDesa.findMany.data = [];
pasarDesa.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch keamanan lingkungan paginated:", err);
pasarDesa.findMany.data = [];
pasarDesa.findMany.totalPages = 1;
} finally {
pasarDesa.findMany.loading = false;
}
},
},
findUnique: {
data: null as Prisma.PasarDesaGetPayload<{
include: {
image: true;
KategoriToPasar: {
include: {
kategori: true;
};
};
};
}> | null,
async load(id: string) {
try {
const res = await fetch(`/api/ekonomi/pasardesa/${id}`);
if (res.ok) {
const data = await res.json();
pasarDesa.findUnique.data = data.data ?? null;
} else {
console.error("Failed to fetch data", res.status, res.statusText);
pasarDesa.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching data:", error);
pasarDesa.findUnique.data = null;
}
},
},
delete: {
loading: false,
async byId(id: string) {
if (!id) return toast.warn("ID tidak valid");
try {
pasarDesa.delete.loading = true;
const response = await fetch(`/api/ekonomi/pasardesa/del/${id}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
});
const result = await response.json();
if (response.ok && result?.success) {
toast.success(result.message || "Pasar desa berhasil dihapus");
await pasarDesa.findMany.load(); // refresh list
} else {
toast.error(result?.message || "Gagal menghapus pasar desa");
}
} catch (error) {
console.error("Gagal delete:", error);
toast.error("Terjadi kesalahan saat menghapus pasar desa");
} finally {
pasarDesa.delete.loading = false;
}
},
},
edit: {
id: "",
form: { ...defaultPasarDesaForm },
loading: false,
async load(id: string) {
if (!id) {
toast.warn("ID tidak valid");
return null;
}
try {
const response = await fetch(`/api/ekonomi/pasardesa/${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 = {
nama: data.nama,
harga: data.harga,
alamatUsaha: data.alamatUsaha,
imageId: data.imageId,
rating: data.rating,
kategoriId: data.kategoriId,
kontak: data.kontak,
deskripsi: data.deskripsi
};
return data;
} else {
throw new Error(result?.message || "Gagal memuat data");
}
} catch (error) {
console.error("Error loading pasar desa:", error);
toast.error(
error instanceof Error ? error.message : "Gagal memuat data"
);
return null;
}
},
async update() {
const cek = templatePasarDesaForm.safeParse(pasarDesa.edit.form);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
return toast.error(err);
}
try {
pasarDesa.edit.loading = true;
const response = await fetch(`/api/ekonomi/pasardesa/${this.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
nama: this.form.nama,
harga: this.form.harga,
alamatUsaha: this.form.alamatUsaha,
imageId: this.form.imageId,
rating: this.form.rating,
kategoriId: this.form.kategoriId,
kontak: this.form.kontak,
deskripsi: this.form.deskripsi
}),
});
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("Berhasil update pasar desa");
await pasarDesa.findMany.load(); // refresh list
return true;
} else {
throw new Error(result.message || "Gagal mengupdate pasar desa");
}
} catch (error) {
console.error("Error updating pasar desa:", error);
toast.error(
error instanceof Error ? error.message : "Gagal mengupdate pasar desa"
);
return false;
} finally {
pasarDesa.edit.loading = false;
}
},
reset() {
pasarDesa.edit.id = "";
pasarDesa.edit.form = { ...defaultPasarDesaForm };
},
},
});
// ========================================= KATEGORI PRODUK ========================================= //
const kategoriProdukForm = z.object({
nama: z.string().min(1, "Nama minimal 1 karakter"),
});
const kategoriProdukDefaultForm = {
nama: "",
};
const kategoriProduk = proxy({
create: {
form: { ...kategoriProdukDefaultForm },
loading: false,
async create() {
const cek = kategoriProdukForm.safeParse(kategoriProduk.create.form);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
return toast.error(err);
}
try {
kategoriProduk.create.loading = true;
const res = await ApiFetch.api.ekonomi.kategoriproduk["create"].post(
kategoriProduk.create.form
);
if (res.status === 200) {
kategoriProduk.findMany.load();
return toast.success("Data berhasil ditambahkan");
}
return toast.error("Gagal menambahkan data");
} catch (error) {
console.log(error);
toast.error("Gagal menambahkan data");
} finally {
kategoriProduk.create.loading = false;
}
},
},
findMany: {
data: null as
| Prisma.KategoriProdukGetPayload<{
omit: {
isActive: true;
};
}>[]
| null,
page: 1,
totalPages: 1,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "") => {
kategoriProduk.findMany.loading = true; // ✅ Akses langsung via nama path
kategoriProduk.findMany.page = page;
kategoriProduk.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
const res = await ApiFetch.api.ekonomi.kategoriproduk["find-many"].get({ query });
if (res.status === 200 && res.data?.success) {
kategoriProduk.findMany.data = res.data.data ?? [];
kategoriProduk.findMany.totalPages = res.data.totalPages ?? 1;
} else {
kategoriProduk.findMany.data = [];
kategoriProduk.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch kategori produk paginated:", err);
kategoriProduk.findMany.data = [];
kategoriProduk.findMany.totalPages = 1;
} finally {
kategoriProduk.findMany.loading = false;
}
},
},
// ✅ Versi findManyAll (ambil semua tanpa pagination)
findManyAll: {
data: null as
| Prisma.KategoriProdukGetPayload<{
omit: { isActive: true };
}>[]
| null,
loading: false,
search: "",
load: async (search = "") => {
kategoriProduk.findManyAll.loading = true;
kategoriProduk.findManyAll.search = search;
try {
const query: any = {};
if (search) query.search = search;
const res = await ApiFetch.api.ekonomi.kategoriproduk["find-many-all"].get({
query,
});
if (res.status === 200 && res.data?.success) {
kategoriProduk.findManyAll.data = res.data.data ?? [];
} else {
kategoriProduk.findManyAll.data = [];
}
} catch (err) {
console.error("Gagal fetch kategori produk (all):", err);
kategoriProduk.findManyAll.data = [];
} finally {
kategoriProduk.findManyAll.loading = false;
}
},
},
findUnique: {
data: null as Prisma.KategoriProdukGetPayload<{
omit: { isActive: true };
}> | null,
async load(id: string) {
try {
const res = await fetch(`/api/ekonomi/kategoriproduk/${id}`);
if (res.ok) {
const data = await res.json();
kategoriProduk.findUnique.data = data.data ?? null;
} else {
console.error("Failed to fetch data", res.status, res.statusText);
kategoriProduk.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching data:", error);
kategoriProduk.findUnique.data = null;
}
},
},
delete: {
loading: false,
async byId(id: string) {
if (!id) return toast.warn("ID tidak valid");
try {
kategoriProduk.delete.loading = true;
const response = await fetch(`/api/ekonomi/kategoriproduk/del/${id}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
});
const result = await response.json();
if (response.ok && result?.success) {
toast.success(result.message || "Kategori produk berhasil dihapus");
await kategoriProduk.findMany.load(); // refresh list
} else {
toast.error(result?.message || "Gagal menghapus kategori produk");
}
} catch (error) {
console.error("Gagal delete:", error);
toast.error("Terjadi kesalahan saat menghapus kategori produk");
} finally {
kategoriProduk.delete.loading = false;
}
},
},
edit: {
id: "",
form: { ...kategoriProdukDefaultForm },
loading: false,
async load(id: string) {
if (!id) {
toast.warn("ID tidak valid");
return null;
}
try {
const response = await fetch(`/api/ekonomi/kategoriproduk/${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 = {
nama: data.nama,
};
return data;
} else {
throw new Error(result?.message || "Gagal memuat data");
}
} catch (error) {
console.error("Error loading kategori produk:", error);
toast.error(
error instanceof Error ? error.message : "Gagal memuat data"
);
return null;
}
},
async update() {
const cek = kategoriProdukForm.safeParse(kategoriProduk.edit.form);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
toast.error(err);
return false;
}
try {
kategoriProduk.edit.loading = true;
const response = await fetch(
`/api/ekonomi/kategoriproduk/${kategoriProduk.edit.id}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
nama: kategoriProduk.edit.form.nama,
}),
}
);
// Clone the response to avoid 'body already read' error
const responseClone = response.clone();
try {
const result = await response.json();
if (!response.ok) {
console.error(
"Update failed with status:",
response.status,
"Response:",
result
);
throw new Error(
result?.message ||
`Gagal mengupdate kategori produk (${response.status})`
);
}
if (result.success) {
toast.success(
result.message || "Berhasil memperbarui kategori produk"
);
await kategoriProduk.findMany.load(); // refresh list
return true;
} else {
throw new Error(
result.message || "Gagal mengupdate kategori produk"
);
}
} catch (error) {
// If JSON parsing fails, try to get the response text for better error messages
try {
const text = await responseClone.text();
console.error("Error response text:", text);
throw new Error(`Gagal memproses respons dari server: ${text}`);
} catch (textError) {
console.error("Error parsing response as text:", textError);
console.error("Original error:", error);
throw new Error("Gagal memproses respons dari server");
}
}
} catch (error) {
console.error("Error updating kategori produk:", error);
toast.error(
error instanceof Error
? error.message
: "Gagal mengupdate kategori produk"
);
return false;
} finally {
kategoriProduk.edit.loading = false;
}
},
reset() {
kategoriProduk.edit.id = "";
kategoriProduk.edit.form = { ...kategoriProdukDefaultForm };
},
},
});
const pasarDesaState = proxy({
pasarDesa,
kategoriProduk,
});
export default pasarDesaState;

View File

@@ -167,6 +167,20 @@ export const umkmState = proxy({
} catch (e) { console.error(e); } finally { this.loading = false; }
}
},
findUnique: {
data: null as any,
loading: false,
async load(id: string) {
this.loading = true;
try {
const res = await fetch(`/api/ekonomi/umkm/produk/${id}`);
const result = await res.json();
if (result.success) {
this.data = result.data;
}
} catch (e) { console.error(e); } finally { this.loading = false; }
}
},
create: {
form: { ...defaultProdukForm },
loading: false,

View File

@@ -155,11 +155,11 @@ const kegiatanDesa = proxy({
toast.success(result.message || "kegiatan desa berhasil dihapus");
await kegiatanDesa.findMany.load(); // refresh list
} else {
toast.error(result?.message || "Gagal menghapus pasar desa");
toast.error(result?.message || "Gagal menghapus gotong royong");
}
} catch (error) {
console.error("Gagal delete:", error);
toast.error("Terjadi kesalahan saat menghapus pasar desa");
toast.error("Terjadi kesalahan saat menghapus gotong royong");
} finally {
kegiatanDesa.delete.loading = false;
}

View File

@@ -1,162 +0,0 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import colors from '@/con/colors';
import {
Box,
ScrollArea,
Stack,
Tabs,
TabsList,
TabsPanel,
TabsTab,
Title
} from '@mantine/core';
import { IconCategory, IconShoppingBag } from '@tabler/icons-react';
import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
function LayoutTabs({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const tabs = [
{
label: "Produk Pasar Desa",
value: "produkpasardesa",
href: "/admin/ekonomi/pasar-desa/produk-pasar-desa",
icon: <IconShoppingBag size={18} stroke={1.8} />
},
{
label: "Kategori Produk",
value: "kategoriproduk",
href: "/admin/ekonomi/pasar-desa/kategori-produk",
icon: <IconCategory size={18} stroke={1.8} />
},
];
const currentTab = tabs.find((tab) => tab.href === pathname);
const [activeTab, setActiveTab] = useState<string | null>(
currentTab?.value || tabs[0].value
);
const handleTabChange = (value: string | null) => {
const tab = tabs.find((t) => t.value === value);
if (tab) {
router.push(tab.href);
}
setActiveTab(value);
};
useEffect(() => {
const match = tabs.find((tab) => tab.href === pathname);
if (match) {
setActiveTab(match.value);
}
}, [pathname]);
return (
<Stack gap="lg">
<Title order={2} fw={700} style={{ color: "#1A1B1E" }}>
Pasar Desa
</Title>
<Tabs
color={colors['blue-button']}
variant="pills"
value={activeTab}
onChange={handleTabChange}
radius="lg"
keepMounted={false}
>
{/* ✅ Scroll horizontal wrapper */}
<Box visibleFrom='md' pb={10}>
<ScrollArea type="auto" offsetScrollbars>
<TabsList
p="sm"
style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
boxShadow: "inset 0 0 10px rgba(0,0,0,0.05)",
display: "flex",
flexWrap: "nowrap",
gap: "0.5rem",
paddingInline: "0.5rem", // ✅ biar nggak nempel ke tepi
}}
>
{tabs.map((tab, i) => (
<TabsTab
key={i}
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
transition: "all 0.2s ease",
flexShrink: 0, // ✅ jangan mengecil aneh-aneh
}}
>
{tab.label}
</TabsTab>
))}
</TabsList>
</ScrollArea>
</Box>
<Box hiddenFrom='md' pb={10}>
<ScrollArea
type="auto"
offsetScrollbars={false}
w="100%"
>
<TabsList
p="xs" // lebih kecil
style={{
background: "linear-gradient(135deg, #e7ebf7, #f9faff)",
borderRadius: "1rem",
display: "flex",
flexWrap: "nowrap",
gap: "0.5rem",
width: "max-content", // ⬅️ kunci
maxWidth: "100%", // ⬅️ penting
}}
>
{tabs.map((tab, i) => (
<TabsTab
key={i}
value={tab.value}
leftSection={tab.icon}
style={{
fontWeight: 600,
fontSize: "0.9rem",
paddingInline: "0.75rem", // ⬅️ lebih ramping
flexShrink: 0, // ✅ jangan mengecil aneh-aneh
}}
>
{tab.label}
</TabsTab>
))}
</TabsList>
</ScrollArea>
</Box>
{tabs.map((tab, i) => (
<TabsPanel
key={i}
value={tab.value}
style={{
padding: "1.5rem",
background: "linear-gradient(180deg, #ffffff, #f5f6fa)",
borderRadius: "1rem",
boxShadow: "0 4px 16px rgba(0,0,0,0.05)",
}}
>
{children}
</TabsPanel>
))}
</Tabs>
</Stack>
);
}
export default LayoutTabs;

View File

@@ -1,172 +0,0 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
Text,
TextInput,
Title
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
import pasarDesaState from '../../../../_state/ekonomi/pasar-desa/pasar-desa';
function EditKategoriProduk() {
const router = useRouter();
const params = useParams();
const id = params?.id as string;
const statePasar = useProxy(pasarDesaState.kategoriProduk);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState({ nama: '' });
const [originalData, setOriginalData] = useState({ nama: '' });
// Check if form is valid
const isFormValid = () => {
return formData.nama?.trim() !== '';
};
useEffect(() => {
const loadKategoriProduk = async () => {
if (!id) return;
try {
const data = await statePasar.edit.load(id);
if (data) {
// simpan id ke state global hanya untuk referensi
statePasar.edit.id = id;
// simpan data ke state lokal
setFormData({ nama: data.nama || '' });
setOriginalData({ nama: data.nama || '' });
}
} catch (error) {
console.error('Error loading kategori produk:', error);
toast.error('Gagal memuat data kategori produk');
}
};
loadKategoriProduk();
}, [id]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData((prev) => ({
...prev,
[e.target.name]: e.target.value,
}));
};
const handleResetForm = () => {
setFormData({
nama: originalData.nama,
});
toast.info('Form dikembalikan ke data awal');
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
if (!formData.nama.trim()) {
toast.error('Nama kategori produk tidak boleh kosong');
return;
}
// update global state hanya saat submit
statePasar.edit.form = { nama: formData.nama.trim() };
if (!statePasar.edit.id) {
statePasar.edit.id = id; // fallback
}
const success = await statePasar.edit.update();
if (success) {
toast.success('Kategori produk berhasil diperbarui!');
router.push('/admin/ekonomi/pasar-desa/kategori-produk');
}
} catch (error) {
console.error('Error updating kategori produk:', error);
toast.error('Terjadi kesalahan saat memperbarui kategori produk');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'xs' }} py="xs">
{/* Header dengan tombol back */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Kategori Produk
</Title>
</Group>
{/* Card form */}
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput
name="nama"
label={<Text fw="bold" fz="sm">Nama Kategori Produk</Text>}
placeholder="Masukkan nama kategori produk"
value={formData.nama}
onChange={handleChange}
required
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default EditKategoriProduk;

View File

@@ -1,127 +0,0 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
Title
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
import pasarDesaState from '../../../../_state/ekonomi/pasar-desa/pasar-desa';
function CreateKategoriProduk() {
const router = useRouter();
const statePasar = useProxy(pasarDesaState.kategoriProduk);
const [isSubmitting, setIsSubmitting] = useState(false);
// Check if form is valid
const isFormValid = () => {
return statePasar.create.form.nama?.trim() !== '';
};
useEffect(() => {
statePasar.findMany.load();
}, []);
const resetForm = () => {
statePasar.create.form = {
nama: '',
};
};
const handleSubmit = async () => {
try {
if (!statePasar.create.form.nama) {
return toast.warn('Nama kategori produk wajib diisi');
}
setIsSubmitting(true);
await statePasar.create.create();
resetForm();
router.push('/admin/ekonomi/pasar-desa/kategori-produk');
} catch (error) {
console.error(error)
toast.error('Gagal menambahkan kategori produk');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'xs' }} py="xs">
{/* Header dengan tombol kembali */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Tambah Kategori Produk
</Title>
</Group>
{/* Card form */}
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput
label="Nama Kategori Produk"
placeholder="Masukkan nama kategori produk"
value={statePasar.create.form.nama || ''}
onChange={(e) => (statePasar.create.form.nama = e.target.value)}
required
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default CreateKategoriProduk;

View File

@@ -1,262 +0,0 @@
'use client'
import colors from '@/con/colors';
import {
Box,
Button,
Center,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title,
} from '@mantine/core';
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { IconEdit, IconPlus, IconSearch, IconX } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header';
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
import pasarDesaState from '../../../_state/ekonomi/pasar-desa/pasar-desa';
function KategoriProduk() {
const [search, setSearch] = useState('');
return (
<Box>
<HeaderSearch
title='Kategori Produk'
placeholder='Cari nama kategori produk...'
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<ListKategoriProduk search={search} />
</Box>
);
}
function ListKategoriProduk({ search }: { search: string }) {
const statePasar = useProxy(pasarDesaState.kategoriProduk);
const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const router = useRouter();
const [debouncedSearch] = useDebouncedValue(search, 1000);
const { data, page, totalPages, loading, load } = statePasar.findMany;
useShallowEffect(() => {
load(page, 10, debouncedSearch);
}, [page, debouncedSearch]);
const handleHapus = () => {
if (selectedId) {
statePasar.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
}
};
const filteredData = data || [];
if (loading || !data) {
return (
<Stack py="md">
<Skeleton height={500} radius="md" />
</Stack>
);
}
return (
<Box py="md">
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify="space-between" mb="md">
<Title order={4} lh={1.2}>
Daftar Kategori Produk
</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() =>
router.push('/admin/ekonomi/pasar-desa/kategori-produk/create')
}
>
Tambah Baru
</Button>
</Group>
{/* Desktop Table */}
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
<Table
highlightOnHover
miw={0}
style={{
tableLayout: 'fixed',
width: '100%',
}}
>
<TableThead>
<TableTr>
<TableTh style={{ width: '60%' }}>
<Text fz="sm" fw={600} lh={1.4}>
Nama Kategori
</Text>
</TableTh>
<TableTh style={{ width: '20%' }}>
<Text fz="sm" fw={600} lh={1.4} ta="center">
Edit
</Text>
</TableTh>
<TableTh style={{ width: '20%' }}>
<Text fz="sm" fw={600} lh={1.4} ta="center">
Delete
</Text>
</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd>
<Text fz="md" fw={500} lh={1.5} truncate="end">
{item.nama}
</Text>
</TableTd>
<TableTd>
<Center>
<Button
color="green"
variant="light"
onClick={() =>
router.push(
`/admin/ekonomi/pasar-desa/kategori-produk/${item.id}`
)
}
>
<IconEdit size={18} />
</Button>
</Center>
</TableTd>
<TableTd>
<Center>
<Button
color="red"
variant="light"
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
>
<IconX size={18} />
</Button>
</Center>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={3}>
<Center py="xl">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data kategori produk yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
{/* Mobile Card */}
<Box hiddenFrom="md">
{filteredData.length > 0 ? (
<Stack gap="sm">
{filteredData.map((item) => (
<Paper
key={item.id}
withBorder
p="md"
radius="md"
bg={colors['white-1']}
>
<Box mb="xs">
<Text fz="sm" fw={600} lh={1.4}>
Nama Kategori
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.nama}
</Text>
</Box>
<Group justify="flex-end" mt="md">
<Button
color="green"
variant="light"
onClick={() =>
router.push(
`/admin/ekonomi/pasar-desa/kategori-produk/${item.id}`
)
}
>
<IconEdit size={18} />
</Button>
<Button
color="red"
variant="light"
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
>
<IconX size={18} />
</Button>
</Group>
</Paper>
))}
</Stack>
) : (
<Center py="xl">
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data kategori produk yang cocok
</Text>
</Center>
)}
</Box>
</Paper>
<Center>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
mt="md"
mb="md"
color="blue"
radius="md"
/>
</Center>
{/* Modal Konfirmasi Hapus */}
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleHapus}
text='Apakah anda yakin ingin menghapus kategori produk ini?'
/>
</Box>
);
}
export default KategoriProduk;

View File

@@ -1,32 +0,0 @@
'use client'
import { usePathname } from "next/navigation";
import LayoutTabs from "./_lib/layoutTabs"
import { Box } from "@mantine/core";
export default function Layout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
// Contoh path:
// - /darmasaba/desa/berita/semua → panjang 5 → list
// - /darmasaba/desa/berita/Pemerintahan → panjang 5 → list
// - /darmasaba/desa/berita/Pemerintahan/123 → panjang 6 → detail
const segments = pathname.split('/').filter(Boolean);
const isDetailPage = segments.length >= 5;
if (isDetailPage) {
// Tampilkan tanpa tab menu
return (
<Box>
{children}
</Box>
);
}
return (
<LayoutTabs>
{children}
</LayoutTabs>
)
}

View File

@@ -1,390 +0,0 @@
/* eslint-disable react-hooks/exhaustive-deps */
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
import pasarDesaState from '@/app/admin/(dashboard)/_state/ekonomi/pasar-desa/pasar-desa';
import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import {
ActionIcon,
Box,
Button,
Group,
Image,
Loader,
MultiSelect,
Paper,
Stack,
Text,
TextInput,
Title
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
type FormData = {
nama: string;
harga: number;
alamatUsaha: string;
imageId: string;
rating: number;
kategoriId: string[];
kontak: string;
deskripsi: string;
};
function EditPasarDesa() {
const pasarState = useProxy(pasarDesaState);
const router = useRouter();
const params = useParams();
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState<FormData>({
nama: '',
harga: 0,
alamatUsaha: '',
imageId: '',
rating: 0,
kategoriId: [],
kontak: '',
deskripsi: ''
});
const [originalData, setOriginalData] = useState({
nama: '',
harga: 0,
alamatUsaha: '',
imageId: '',
imageUrl: "",
rating: 0,
kategoriId: [],
kontak: '',
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
useEffect(() => {
pasarState.kategoriProduk.findManyAll.load();
const loadPasarDesa = async () => {
const id = params?.id as string;
if (!id) return;
try {
const data = await pasarState.pasarDesa.edit.load(id);
if (data) {
setFormData({
nama: data.nama || '',
harga: data.harga || 0,
alamatUsaha: data.alamatUsaha || '',
imageId: data.imageId || '',
rating: data.rating || 0,
kategoriId: data.KategoriToPasar?.map((k: any) => k.kategoriId) || [],
kontak: data.kontak || '',
deskripsi: data.deskripsi || ''
});
setOriginalData({
nama: data.nama || '',
harga: data.harga || 0,
alamatUsaha: data.alamatUsaha || '',
imageId: data.imageId || '',
imageUrl: data.image?.link || "",
rating: data.rating || 0,
kategoriId: data.KategoriToPasar?.map((k: any) => k.kategoriId) || [],
kontak: data.kontak || '',
deskripsi: data.deskripsi || ''
});
if (data.image?.link) setPreviewImage(data.image.link);
}
} catch (error) {
console.error('Error loading pasar desa:', error);
toast.error(
error instanceof Error ? error.message : 'Gagal mengambil data pasar desa'
);
}
};
loadPasarDesa();
}, [params?.id]);
const handleChange = (key: keyof FormData, value: any) => {
setFormData((prev) => ({ ...prev, [key]: value }));
};
const handleResetForm = () => {
setFormData({
nama: originalData.nama,
harga: originalData.harga,
alamatUsaha: originalData.alamatUsaha,
imageId: originalData.imageId,
rating: originalData.rating,
kategoriId: (originalData as any)?.KategoriToPasar?.map((k: any) => k.kategoriId) || [],
kontak: originalData.kontak,
deskripsi: originalData.deskripsi
});
setPreviewImage(originalData.imageUrl || null);
setFile(null);
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
// upload image kalau ada file baru
let imageId = formData.imageId;
if (file) {
const res = await ApiFetch.api.fileStorage.create.post({ file, name: file.name });
const uploaded = res.data?.data;
if (!uploaded?.id) return toast.error('Gagal upload gambar');
imageId = uploaded.id;
}
// update global state hanya saat submit
pasarState.pasarDesa.edit.form = {
...formData,
imageId,
};
await pasarState.pasarDesa.edit.update();
toast.success('Pasar desa berhasil diperbarui!');
router.push('/admin/ekonomi/pasar-desa/produk-pasar-desa');
} catch (error) {
console.error('Error updating pasar desa:', error);
toast.error('Terjadi kesalahan saat memperbarui pasar desa');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'xs' }} py="xs">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Pasar Desa
</Title>
</Group>
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
{/* Dropzone upload */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar Produk
</Text>
<Dropzone
onDrop={(files) => {
const selectedFile = files[0];
if (selectedFile) {
setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile));
}
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept>
<IconUpload size={48} color={colors['blue-button']} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="red" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={48} color="#868e96" stroke={1.5} />
</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>
</Dropzone>
{previewImage && (
<Box pos={"relative"} mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
loading="lazy"
/>
{/* Tombol hapus (pojok kanan atas) */}
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
{/* Controlled Inputs */}
<TextInput
label="Nama Produk"
placeholder="Masukkan nama produk"
value={formData.nama}
onChange={(e) => handleChange('nama', e.target.value)}
required
/>
<TextInput
type="number"
label="Harga Produk"
placeholder="Masukkan harga produk"
value={formData.harga}
onChange={(e) => handleChange('harga', Number(e.target.value))}
required
/>
<TextInput
type="number"
min={0}
max={5}
step={0.1}
label="Rating Produk"
placeholder="Masukkan rating produk (0-5)"
value={formData.rating}
onChange={(e) => handleChange('rating', Number(e.target.value))}
required
/>
<TextInput
label="Alamat Usaha"
placeholder="Masukkan alamat usaha"
value={formData.alamatUsaha}
onChange={(e) => handleChange('alamatUsaha', e.target.value)}
required
/>
<TextInput
label="Kontak"
placeholder="Masukkan kontak"
value={formData.kontak}
onChange={(e) => handleChange('kontak', e.target.value)}
required
/>
<MultiSelect
label="Kategori Produk"
placeholder="Pilih kategori produk"
value={formData.kategoriId}
onChange={(val) => handleChange('kategoriId', val)}
data={
pasarState.kategoriProduk.findManyAll.data?.map((v) => ({
value: v.id,
label: v.nama,
})) || []
}
clearable
searchable
required
error={!formData.kategoriId.length ? 'Pilih minimal satu kategori' : undefined}
/>
{/* Input Deskripsi */}
<Box>
<Text fz="sm" fw="bold" mb={6}>
Deskripsi
</Text>
<EditEditor
value={formData.deskripsi}
onChange={(htmlContent) =>
setFormData((prev) => ({ ...prev, deskripsi: htmlContent }))
}
/>
</Box>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default EditPasarDesa;

View File

@@ -1,164 +0,0 @@
'use client'
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import colors from '@/con/colors';
import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import pasarDesaState from '../../../../_state/ekonomi/pasar-desa/pasar-desa';
function DetailPasarDesa() {
const statePasar = useProxy(pasarDesaState);
const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const params = useParams();
const router = useRouter();
useShallowEffect(() => {
statePasar.pasarDesa.findUnique.load(params?.id as string);
}, []);
const handleHapus = () => {
if (selectedId) {
statePasar.pasarDesa.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
router.push("/admin/ekonomi/pasar-desa/produk-pasar-desa");
}
};
if (!statePasar.pasarDesa.findUnique.data) {
return (
<Stack py={10}>
<Skeleton height={500} radius="md" />
</Stack>
);
}
const data = statePasar.pasarDesa.findUnique.data;
return (
<Box px={{ base: 0, md: 'xs' }} py="xs">
<Button
variant="subtle"
onClick={() => router.back()}
leftSection={<IconArrowBack size={24} color={colors['blue-button']} />}
mb={15}
>
Kembali
</Button>
<Paper
withBorder
w={{ base: "100%", md: "70%" }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
>
<Stack gap="md">
<Text fz="2xl" fw="bold" c={colors['blue-button']}>
Detail Pasar Desa
</Text>
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="sm">
<Box>
<Text fz="lg" fw="bold">Nama Produk</Text>
<Text fz="md" c="dimmed">{data.nama || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Harga Produk</Text>
<Text fz="md" c="dimmed">Rp. {data.harga || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Rating Produk</Text>
<Text fz="md" c="dimmed">{data.rating || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Alamat Usaha</Text>
<Text fz="md" c="dimmed">{data.alamatUsaha || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Kontak</Text>
<Text fz="md" c="dimmed">{data.kontak || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Gambar</Text>
{data.image?.link ? (
<Image
src={data.image.link}
alt={data.nama || 'Gambar Produk'}
w={120}
h={120}
radius="md"
fit="cover"
loading="lazy"
/>
) : (
<Text fz="sm" c="dimmed">Tidak ada gambar</Text>
)}
</Box>
<Box>
<Text fz="lg" fw="bold">Kategori</Text>
<Stack gap={4}>
{data.KategoriToPasar && data.KategoriToPasar.length > 0 ? (
data.KategoriToPasar.map((kategori) => (
<Text fz="md" c="dimmed" key={kategori.id}>
{kategori.kategori.nama}
</Text>
))
) : (
<Text fz="sm" c="dimmed">Tidak ada kategori</Text>
)}
</Stack>
</Box>
<Group gap="sm">
<Button
color="red"
onClick={() => {
setSelectedId(data.id);
setModalHapus(true);
}}
variant="light"
radius="md"
size="md"
>
<IconTrash size={20} />
</Button>
<Button
color="green"
onClick={() => router.push(`/admin/ekonomi/pasar-desa/produk-pasar-desa/${data.id}/edit`)}
variant="light"
radius="md"
size="md"
>
<IconEdit size={20} />
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Paper>
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleHapus}
text="Apakah Anda yakin ingin menghapus produk ini?"
/>
</Box>
);
}
export default DetailPasarDesa;

View File

@@ -1,302 +0,0 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import {
ActionIcon,
Box,
Button,
Group,
Image,
Loader,
MultiSelect,
Paper,
Stack,
Text,
TextInput,
Title
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
import pasarDesaState from '../../../../_state/ekonomi/pasar-desa/pasar-desa';
import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
export default function CreatePasarDesa() {
const router = useRouter();
const statePasar = useProxy(pasarDesaState);
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Helper function to check if HTML content is empty
const isHtmlEmpty = (html: string) => {
// Remove all HTML tags and check if there's any text content
const textContent = html.replace(/<[^>]*>/g, '').trim();
return textContent === '';
};
// Check if form is valid
const isFormValid = () => {
return (
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(() => {
statePasar.kategoriProduk.findManyAll.load();
}, []);
const resetForm = () => {
statePasar.pasarDesa.create.form = {
nama: '',
harga: 0,
alamatUsaha: '',
imageId: '',
rating: 0,
kategoriId: [],
kontak: '',
deskripsi: ''
};
setPreviewImage(null);
setFile(null);
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
if (!file) {
return toast.warn('Silakan pilih file gambar terlebih dahulu');
}
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
});
const uploaded = res.data?.data;
if (!uploaded?.id) {
return toast.error('Gagal mengunggah gambar, silakan coba lagi');
}
statePasar.pasarDesa.create.form.imageId = uploaded.id;
await statePasar.pasarDesa.create.create();
resetForm();
router.push('/admin/ekonomi/pasar-desa/produk-pasar-desa');
} catch (error) {
console.error('Error creating kategori produk:', error);
toast.error('Gagal membuat kategori produk');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'xs' }} py="xs">
{/* Header dengan tombol kembali */}
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Tambah Produk Pasar Desa
</Title>
</Group>
{/* Card Form */}
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
{/* Upload Gambar */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar Produk
</Text>
<Dropzone
onDrop={(files) => {
const selectedFile = files[0];
if (selectedFile) {
setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile));
}
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept>
<IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
</Dropzone.Idle>
</Group>
<Text ta="center" mt="sm" size="sm" color="dimmed">
Seret gambar atau klik untuk memilih file (maks 5MB)
</Text>
</Dropzone>
{previewImage && (
<Box pos={"relative"} mt="sm" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
loading="lazy"
/>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
{/* Nama Produk */}
<TextInput
label="Nama Produk"
placeholder="Masukkan nama produk"
value={statePasar.pasarDesa.create.form.nama}
onChange={(e) => (statePasar.pasarDesa.create.form.nama = e.target.value)}
required
/>
{/* Harga Produk */}
<TextInput
type="number"
label="Harga Produk"
placeholder="Masukkan harga produk"
value={statePasar.pasarDesa.create.form.harga}
onChange={(e) => (statePasar.pasarDesa.create.form.harga = Number(e.target.value))}
required
/>
{/* Rating */}
<TextInput
type="number"
min={0}
max={5}
step={0.1}
label="Rating Produk (05)"
placeholder="Masukkan rating produk"
value={statePasar.pasarDesa.create.form.rating}
onChange={(e) => {
const value = Number(e.target.value);
if (value >= 0 && value <= 5) {
statePasar.pasarDesa.create.form.rating = value;
}
}}
/>
{/* Alamat Usaha */}
<TextInput
label="Alamat Usaha"
placeholder="Masukkan alamat usaha"
value={statePasar.pasarDesa.create.form.alamatUsaha}
onChange={(e) => (statePasar.pasarDesa.create.form.alamatUsaha = e.target.value)}
/>
{/* Kontak */}
<TextInput
label="Kontak"
type="number"
placeholder="Masukkan kontak"
value={statePasar.pasarDesa.create.form.kontak}
onChange={(e) => (statePasar.pasarDesa.create.form.kontak = e.target.value)}
/>
{/* Kategori Produk */}
<MultiSelect
label="Kategori Produk"
placeholder="Pilih kategori produk"
value={statePasar.pasarDesa.create.form.kategoriId}
onChange={(val) => (statePasar.pasarDesa.create.form.kategoriId = val)}
data={
statePasar.kategoriProduk.findManyAll.data?.map((v) => ({
value: v.id,
label: v.nama,
})) || []
}
/>
<Box>
<Text fw="bold" fz="sm" mb={6}>
Deskripsi Produk
</Text>
<CreateEditor
value={statePasar.pasarDesa.create.form.deskripsi}
onChange={(val) => {
statePasar.pasarDesa.create.form.deskripsi = val;
}}
/>
</Box>
{/* Tombol Submit */}
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}

View File

@@ -1,223 +0,0 @@
'use client'
import colors from '@/con/colors';
import {
Box,
Button,
Center,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title
} from '@mantine/core';
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { IconDeviceImac, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header';
import pasarDesaState from '../../../_state/ekonomi/pasar-desa/pasar-desa';
function PasarDesa() {
const [search, setSearch] = useState("");
return (
<Box>
<HeaderSearch
title="Produk Pasar Desa"
placeholder="Cari produk pasar desa..."
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<ListPasarDesa search={search} />
</Box>
);
}
function ListPasarDesa({ search }: { search: string }) {
const statePasar = useProxy(pasarDesaState.pasarDesa);
const router = useRouter();
const [debouncedSearch] = useDebouncedValue(search, 1000);
const { data, page, totalPages, loading, load } = statePasar.findMany;
useShallowEffect(() => {
load(page, 10, debouncedSearch);
}, [page, debouncedSearch]);
const filteredData = data || [];
if (loading || !data) {
return (
<Stack py="lg">
<Skeleton height={600} radius="md" />
</Stack>
);
}
return (
<Box py="lg">
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify="space-between" mb="md">
<Title order={4} lh={1.2}>Daftar Produk Pasar Desa</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() =>
router.push('/admin/ekonomi/pasar-desa/produk-pasar-desa/create')
}
>
Tambah Baru
</Button>
</Group>
{/* Desktop Table */}
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
<Table
highlightOnHover
miw={0}
style={{
tableLayout: 'fixed',
width: '100%',
}}
>
<TableThead>
<TableTr>
<TableTh style={{ width: '25%' }}><Text fz="sm" fw={600} lh={1.4}>Nama Produk</Text></TableTh>
<TableTh style={{ width: '20%' }}><Text fz="sm" fw={600} lh={1.4}>Harga Produk</Text></TableTh>
<TableTh style={{ width: '15%' }}><Text fz="sm" fw={600} lh={1.4}>Rating</Text></TableTh>
<TableTh style={{ width: '25%' }}><Text fz="sm" fw={600} lh={1.4}>Alamat Usaha</Text></TableTh>
<TableTh style={{ width: '15%' }}><Text fz="sm" fw={600} lh={1.4}>Aksi</Text></TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd>
<Text fz="md" fw={500} lh={1.5} truncate="end" lineClamp={1}>
{item.nama}
</Text>
</TableTd>
<TableTd>
<Text fz="md" lh={1.5}>Rp.{item.harga}</Text>
</TableTd>
<TableTd>
<Text fz="md" lh={1.5}>{item.rating || '-'}</Text>
</TableTd>
<TableTd>
<Text fz="sm" lh={1.5} c="dimmed">
{item.alamatUsaha || '-'}
</Text>
</TableTd>
<TableTd>
<Button
variant="light"
color="blue"
onClick={() =>
router.push(
`/admin/ekonomi/pasar-desa/produk-pasar-desa/${item.id}`
)
}
>
<IconDeviceImac size={20} />
<Text ml={5} fz="sm" fw={500} lh={1.4}>Detail</Text>
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={5}>
<Center py={32}>
<Text c="dimmed" fz="sm" lh={1.5}>
Tidak ada produk pasar desa yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
{/* Mobile Cards */}
<Stack hiddenFrom="md" gap="sm">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<Paper key={item.id} withBorder p="md" radius="md">
<Stack gap={'xs'}>
<Box>
<Text fz="sm" fw={600} lh={1.4}>Nama Produk</Text>
<Text fz="sm" fw={500} lh={1.4}>{item.nama}</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>Harga Produk</Text>
<Text fz="sm" fw={500} lh={1.4}>Rp.{item.harga}</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>Rating</Text>
<Text fz="sm" fw={500} lh={1.4}>{item.rating || '-'}</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>Alamat Usaha</Text>
<Text fz="sm" fw={500} lh={1.4} c="dimmed">
{item.alamatUsaha || '-'}
</Text>
</Box>
<Box>
<Button
fullWidth
variant="light"
color="blue"
onClick={() =>
router.push(
`/admin/ekonomi/pasar-desa/produk-pasar-desa/${item.id}`
)
}
>
<IconDeviceImac size={20} />
<Text ml={5} fz="sm" fw={500} lh={1.4}>Detail</Text>
</Button>
</Box>
</Stack>
</Paper>
))
) : (
<Center py={32}>
<Text c="dimmed" fz="sm" lh={1.5}>
Tidak ada produk pasar desa yang cocok
</Text>
</Center>
)}
</Stack>
</Paper>
<Center>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
mt="md"
mb="md"
color="blue"
radius="md"
/>
</Center>
</Box>
);
}
export default PasarDesa;

View File

@@ -208,29 +208,9 @@ export const devBar = [
children: [
{
id: "Ekonomi_UMKM_1",
name: "UMKM - Dashboard",
name: "UMKM",
path: "/admin/ekonomi/umkm/dashboard"
},
{
id: "Ekonomi_UMKM_2",
name: "UMKM - Data UMKM",
path: "/admin/ekonomi/umkm/data-umkm"
},
{
id: "Ekonomi_UMKM_3",
name: "UMKM - Produk",
path: "/admin/ekonomi/umkm/produk"
},
{
id: "Ekonomi_UMKM_4",
name: "UMKM - Penjualan",
path: "/admin/ekonomi/umkm/penjualan"
},
{
id: "Ekonomi_1",
name: "Pasar Desa",
path: "/admin/ekonomi/pasar-desa/produk-pasar-desa"
},
{
id: "Ekonomi_2",
name: "Lowongan Kerja Lokal",
@@ -677,11 +657,6 @@ export const navBar = [
name: "UMKM - Penjualan",
path: "/admin/ekonomi/umkm/penjualan"
},
{
id: "Ekonomi_1",
name: "Pasar Desa",
path: "/admin/ekonomi/pasar-desa/produk-pasar-desa"
},
{
id: "Ekonomi_2",
name: "Lowongan Kerja Lokal",
@@ -1086,11 +1061,6 @@ export const role1 = [
name: "UMKM - Penjualan",
path: "/admin/ekonomi/umkm/penjualan"
},
{
id: "Ekonomi_1",
name: "Pasar Desa",
path: "/admin/ekonomi/pasar-desa/produk-pasar-desa"
},
{
id: "Ekonomi_2",
name: "Lowongan Kerja Lokal",

View File

@@ -1,8 +1,6 @@
import Elysia from "elysia";
import PasarDesa from "./pasar-desa";
import LowonganKerja from "./lowongan-kerja";
import ProgramKemiskinan from "./program-kemiskinan";
import KategoriProduk from "./pasar-desa/kategori-produk";
import GrafikUsiaKerjaYangMenganggur from "./usia-kerja-yang-menganggur";
import GrafikMenganggurBerdasarkanPendidikan from "./usia-kerja-yang-menganggur/pengangguran-berdasrkan-pendidikan";
import JumlahPendudukMiskin from "./jumlah-penduduk-miskin";
@@ -20,8 +18,6 @@ const Ekonomi = new Elysia({
prefix: "/ekonomi",
tags: ["Ekonomi"],
})
.use(PasarDesa)
.use(KategoriProduk)
.use(LowonganKerja)
.use(ProgramKemiskinan)
.use(StrukturOrganisasi)

View File

@@ -1,73 +0,0 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
type FormCreate = {
nama: string;
harga: number;
alamatUsaha: string;
imageId: string;
rating: number;
kategoriId: string[];
kontak: string;
deskripsi: string;
// Array of KategoriProduk IDs
};
export default async function pasarDesaCreate(context: Context) {
const body = context.body as FormCreate;
if (!body.kategoriId || body.kategoriId.length === 0) {
throw new Error("At least one kategoriId is required");
}
try {
// Start a transaction to ensure data consistency
const result = await prisma.$transaction(async (prisma) => {
// 1. Create PasarDesa with the first kategoriId as the main category
const pasarDesa = await prisma.pasarDesa.create({
data: {
nama: body.nama,
harga: Number(body.harga),
alamatUsaha: body.alamatUsaha,
imageId: body.imageId,
rating: Number(body.rating),
kategoriProdukId: body.kategoriId[0],
kontak: body.kontak,
deskripsi: body.deskripsi,
// Use the first category as the main one
},
});
// 2. Create category relationships in KategoriToPasar for all categories
await prisma.kategoriToPasar.createMany({
data: body.kategoriId.map((kategoriId) => ({
pasarDesaId: pasarDesa.id,
kategoriId: kategoriId, // Note: The field is 'kategoriId' in the schema, not 'kategoriProdukId'
})),
});
// 3. Get the complete data with relationships
return await prisma.pasarDesa.findUnique({
where: { id: pasarDesa.id },
include: {
image: true,
kategoriProduk: true,
KategoriToPasar: {
include: {
kategori: true,
},
},
},
});
});
return {
success: true,
message: "Sukses menambahkan pasar desa",
data: result,
};
} catch (error) {
console.error("Error creating PasarDesa:", error);
throw new Error("Failed to create PasarDesa: " + (error as Error).message);
}
}

View File

@@ -1,27 +0,0 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function pasarDesaDelete(context: Context) {
const { params } = context;
const id = params?.id as string;
if (!id) {
throw new Error("ID tidak ditemukan dalam parameter");
}
// 1. Hapus relasi dari pivot
await prisma.kategoriToPasar.deleteMany({
where: { pasarDesaId: id },
});
// 2. Hapus pasar desa utama
const deleted = await prisma.pasarDesa.delete({
where: { id },
});
return {
success: true,
message: "Berhasil menghapus pasar desa",
data: deleted,
};
}

View File

@@ -1,88 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// /api/berita/findManyPaginated.ts
import prisma from "@/lib/prisma";
import { Context } from "elysia";
async function pasarDesaFindMany(context: Context) {
// Ambil parameter dari query
const page = Number(context.query.page) || 1;
const limit = Number(context.query.limit) || 10;
const search = (context.query.search as string) || '';
const categoryId = context.query.categoryId as string | undefined;
const skip = (page - 1) * limit;
// Buat where clause: Tampilkan hanya yang TIDAK punya umkmId (Produk Pasar Murni)
const where: any = {
isActive: true,
deletedAt: null,
umkmId: null
};
// Tambahkan filter kategori (jika ada)
if (categoryId) {
where.KategoriToPasar = {
some: {
kategoriId: categoryId
}
};
}
// Tambahkan pencarian (jika ada)
if (search) {
where.AND = where.AND || [];
where.AND.push({
OR: [
{ nama: { contains: search, mode: 'insensitive' } },
{ alamatUsaha: { contains: search, mode: 'insensitive' } },
{
KategoriToPasar: {
some: {
kategori: {
nama: { contains: search, mode: 'insensitive' }
}
}
}
}
]
});
}
try {
// Ambil data dan total count secara paralel
const [data, total] = await Promise.all([
prisma.pasarDesa.findMany({
where,
include: {
image: true,
KategoriToPasar: {
include: {
kategori: true
}
}
},
skip,
take: limit,
orderBy: { createdAt: 'desc' },
}),
prisma.pasarDesa.count({ where }),
]);
return {
success: true,
message: "Berhasil ambil pasar desa dengan pagination (Non-UMKM)",
data,
page,
limit,
total,
totalPages: Math.ceil(total / limit),
};
} catch (e) {
console.error("Error di findMany paginated:", e);
return {
success: false,
message: "Gagal mengambil data pasar desa",
};
}
}
export default pasarDesaFindMany;

View File

@@ -1,33 +0,0 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function pasarDesaFindUnique(context: Context) {
const { params } = context;
const id = params?.id as string;
if (!id) {
throw new Error("ID tidak ditemukan dalam parameter");
}
const data = await prisma.pasarDesa.findUnique({
where: { id },
include: {
image: true,
KategoriToPasar: {
include: {
kategori: true,
},
},
},
});
if (!data) {
throw new Error("Pasar desa tidak ditemukan");
}
return {
success: true,
message: "Data pasar desa ditemukan",
data,
};
}

View File

@@ -1,90 +0,0 @@
import Elysia, { t } from "elysia";
import pasarDesaCreate from "./create";
import pasarDesaDelete from "./del";
import pasarDesaFindMany from "./findMany";
import pasarDesaUpdate from "./updt";
import pasarDesaFindUnique from "./findUnique";
const PasarDesa = new Elysia({
prefix: "/pasardesa",
tags: ["Ekonomi/Pasar Desa"],
})
// GET all
.get("/find-many", pasarDesaFindMany)
// GET by ID
.get(
"/:id",
async (context) => {
return await pasarDesaFindUnique(context);
},
{
params: t.Object({
id: t.String(),
}),
}
)
// POST create
.post(
"/create",
pasarDesaCreate,
{
body: t.Object({
nama: t.String(),
harga: t.Number(),
alamatUsaha: t.String(),
imageId: t.String(),
rating: t.Number(),
kategoriId: t.Array(t.String()),
kontak: t.String(),
deskripsi: t.String(),
}),
}
)
// DELETE
.delete(
"/del/:id",
pasarDesaDelete,
{
params: t.Object({
id: t.String(),
}),
}
)
// PUT update
.put(
"/:id",
async (context) => {
const body = context.body;
const id = context.params.id;
// Gabungkan id ke body
return await pasarDesaUpdate({
...context,
body: {
...body,
id,
},
});
},
{
params: t.Object({
id: t.String(),
}),
body: t.Object({
nama: t.String(),
harga: t.Number(),
alamatUsaha: t.String(),
imageId: t.String(),
rating: t.Number(),
kategoriId: t.Array(t.String()),
kontak: t.String(),
deskripsi: t.String(),
}),
}
);
export default PasarDesa;

View File

@@ -1,25 +0,0 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function kategoriProdukCreate(context: Context) {
const body = context.body as {nama: string};
if (!body.nama) {
return {
success: false,
message: "Nama is required",
};
}
const kategoriProduk = await prisma.kategoriProduk.create({
data: {
nama: body.nama,
},
});
return {
success: true,
message: "Sukses menambahkan kategori produk",
data: kategoriProduk
};
}

View File

@@ -1,33 +0,0 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
const kategoriProdukDelete = async (context: Context) => {
const id = context.params.id;
if (!id) {
return {
success: false,
message: "ID is required",
}
}
const kategoriProduk = await prisma.kategoriProduk.delete({
where: {
id: id,
},
})
if(!kategoriProduk) {
return {
success: false,
message: "Kategori Produk tidak ditemukan",
}
}
return {
success: true,
message: "Sukses Menghapus kategori produk",
data: kategoriProduk,
}
}
export default kategoriProdukDelete

View File

@@ -1,60 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// /api/berita/findManyPaginated.ts
import prisma from "@/lib/prisma";
import { Context } from "elysia";
async function kategoriProdukFindMany(context: Context) {
// Ambil parameter dari query
const page = Number(context.query.page) || 1;
const limit = Number(context.query.limit) || 10;
const search = (context.query.search as string) || '';
const skip = (page - 1) * limit;
// Buat where clause
const where: any = { isActive: true };
// Tambahkan pencarian (jika ada)
if (search) {
where.OR = [
{ nama: { contains: search, mode: 'insensitive' } },
{KategoriToPasar : {
some: {
kategori: {
nama: { contains: search, mode: 'insensitive' }
}
}
}}
];
}
try {
// Ambil data dan total count secara paralel
const [data, total] = await Promise.all([
prisma.kategoriProduk.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
}),
prisma.kategoriProduk.count({ where }),
]);
return {
success: true,
message: "Berhasil ambil kategori produk dengan pagination",
data,
page,
limit,
total,
totalPages: Math.ceil(total / limit),
};
} catch (e) {
console.error("Error di findMany paginated:", e);
return {
success: false,
message: "Gagal mengambil data kategori produk",
};
}
}
export default kategoriProdukFindMany;

View File

@@ -1,49 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// /api/berita/findManyAll.ts
import prisma from "@/lib/prisma";
import { Context } from "elysia";
async function kategoriProdukFindManyAll(context: Context) {
// Ambil query search (opsional)
const search = (context.query.search as string) || "";
// Buat where clause
const where: any = { isActive: true };
if (search) {
where.OR = [
{ nama: { contains: search, mode: "insensitive" } },
{
KategoriToPasar: {
some: {
kategori: {
nama: { contains: search, mode: "insensitive" },
},
},
},
},
];
}
try {
const data = await prisma.kategoriProduk.findMany({
where,
orderBy: { createdAt: "desc" },
});
return {
success: true,
message: "Berhasil ambil semua kategori produk",
data,
total: data.length,
};
} catch (e) {
console.error("Error di findManyAll:", e);
return {
success: false,
message: "Gagal mengambil data kategori produk",
};
}
}
export default kategoriProdukFindManyAll;

View File

@@ -1,47 +0,0 @@
import { Context } from "elysia";
import prisma from "@/lib/prisma";
export default async function kategoriProdukFindUnique(context: Context) {
const url = new URL(context.request.url);
const pathSegments = url.pathname.split('/');
const id = pathSegments[pathSegments.length - 1];
if (!id) {
return {
success: false,
message: "ID is required",
}
}
try {
if (typeof id !== 'string') {
return {
success: false,
message: "ID is required",
}
}
const data = await prisma.kategoriProduk.findUnique({
where: { id },
});
if (!data) {
return {
success: false,
message: "Kategori makanan tidak ditemukan",
}
}
return {
success: true,
message: "Success find kategori makanan",
data,
}
} catch (error) {
console.error("Find by ID error:", error);
return {
success: false,
message: "Gagal mengambil kategori makanan: " + (error instanceof Error ? error.message : 'Unknown error'),
}
}
}

View File

@@ -1,32 +0,0 @@
import Elysia from "elysia";
import kategoriProdukFindMany from "./findMany";
import kategoriProdukFindUnique from "./findUnique";
import kategoriProdukDelete from "./del";
import kategoriProdukCreate from "./create";
import kategoriProdukUpdate from "./updt";
import { t } from "elysia";
import kategoriProdukFindManyAll from "./findManyAll";
const KategoriProduk = new Elysia({
prefix: "/kategoriproduk",
tags: ["Ekonomi/Kategori Produk"],
})
.get("/find-many", kategoriProdukFindMany)
.get("/find-many-all", kategoriProdukFindManyAll)
.get("/:id", async (context) => {
const response = await kategoriProdukFindUnique(context);
return response;
})
.delete("/del/:id", kategoriProdukDelete)
.post("/create", kategoriProdukCreate, {
body: t.Object({
nama: t.String(),
}),
})
.put("/:id", kategoriProdukUpdate, {
body: t.Object({
nama: t.String(),
}),
});
export default KategoriProduk;

View File

@@ -1,44 +0,0 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function kategoriProdukUpdate(context: Context) {
const body = context.body as { nama: string };
const id = context.params?.id as string;
// Validasi ID dan nama
if (!id) {
return {
success: false,
message: "ID is required",
};
}
if (!body.nama) {
return {
success: false,
message: "Nama is required",
};
}
try {
const kategoriProduk = await prisma.kategoriProduk.update({
where: { id },
data: {
nama: body.nama,
},
});
return {
success: true,
message: "Success update kategori produk",
data: kategoriProduk,
};
} catch (error) {
console.error("Update error:", error);
return {
success: false,
message: "Gagal update kategori produk",
error: error instanceof Error ? error.message : String(error),
};
}
}

View File

@@ -1,72 +0,0 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
type FormUpdate = {
id: string;
nama: string;
harga: number;
alamatUsaha: string;
imageId: string;
rating: number;
kategoriId: string[]; // Array of KategoriProduk IDs
kontak: string;
deskripsi: string;
};
export default async function pasarDesaUpdate(context: Context) {
const body = context.body as FormUpdate;
if (!body.id) {
throw new Error("ID pasar desa tidak boleh kosong");
}
if (!body.kategoriId || body.kategoriId.length === 0) {
throw new Error("Minimal 1 kategori harus dipilih");
}
// 1. Update data utama pasar desa
await prisma.pasarDesa.update({
where: { id: body.id },
data: {
nama: body.nama,
harga: Number(body.harga),
alamatUsaha: body.alamatUsaha,
imageId: body.imageId,
rating: Number(body.rating),
kontak: body.kontak,
deskripsi: body.deskripsi
},
});
// 2. Hapus semua relasi kategori lama
await prisma.kategoriToPasar.deleteMany({
where: { pasarDesaId: body.id },
});
// 3. Tambah relasi kategori yang baru
await prisma.kategoriToPasar.createMany({
data: body.kategoriId.map((kategoriProdukId) => ({
pasarDesaId: body.id,
kategoriId: kategoriProdukId,
})),
});
// 4. Ambil data lengkap setelah update
const updated = await prisma.pasarDesa.findUnique({
where: { id: body.id },
include: {
image: true,
KategoriToPasar: {
include: {
kategori: true,
},
},
},
});
return {
success: true,
message: "Success update pasar desa",
data: updated,
};
}

View File

@@ -22,9 +22,9 @@ async function umkmDashboardDetailPenjualan(context: Context) {
where: { periode: periodeLalu, deletedAt: null },
_sum: { totalNilai: true }
}),
// Use PasarDesa with umkmId filter
// Use PasarDesa
prisma.pasarDesa.findMany({
where: { deletedAt: null, umkmId: { not: null } },
where: { deletedAt: null },
select: { id: true, nama: true, stok: true }
})
]);

View File

@@ -20,9 +20,9 @@ async function umkmDashboardRingSummary(context: Context) {
where: { periode: periodeLalu, deletedAt: null },
_sum: { totalNilai: true }
}),
// Count from PasarDesa with umkmId filter
prisma.pasarDesa.count({
where: { isActive: true, deletedAt: null, umkmId: { not: null } }
// Count from PasarDesa
prisma.pasarDesa.count({
where: { isActive: true, deletedAt: null }
}),
prisma.penjualanProduk.count({ where: { periode, deletedAt: null } })
]);

View File

@@ -10,13 +10,11 @@ async function produkUmkmFindMany(context: Context) {
const kategoriId = context.query.kategoriId as string | undefined;
const skip = (page - 1) * limit;
// Filter: ONLY products that belong to an UMKM
const where: any = {
// Filter: ONLY active products
const where: any = {
deletedAt: null,
isActive: true,
umkmId: { not: null }
};
if (umkmId) {
where.umkmId = umkmId;
}

View File

@@ -20,6 +20,7 @@ const ProdukUmkm = new Elysia({
nama: t.String(),
harga: t.Number(),
umkmId: t.String(),
kategoriId: t.String(), // Added validation
stok: t.Optional(t.Number()),
deskripsi: t.Optional(t.String()),
imageId: t.Optional(t.String()),
@@ -34,6 +35,7 @@ const ProdukUmkm = new Elysia({
nama: t.String(),
harga: t.Number(),
umkmId: t.String(),
kategoriId: t.String(), // Added validation
stok: t.Number(),
deskripsi: t.Optional(t.String()),
imageId: t.Optional(t.String()),

View File

@@ -1,177 +0,0 @@
'use client'
import colors from '@/con/colors';
import { Box, Button, Paper, Stack, Text, Image, Skeleton, Group, Badge, Divider, Title } from '@mantine/core';
import { IconArrowBack, IconBrandWhatsapp, IconMapPin, IconPhone, IconStar } from '@tabler/icons-react';
import { useRouter, useParams } from 'next/navigation';
import React from 'react';
import { useProxy } from 'valtio/utils';
import { useShallowEffect } from '@mantine/hooks';
import pasarDesaState from '@/app/admin/(dashboard)/_state/ekonomi/pasar-desa/pasar-desa';
function DetailProdukPasarUser() {
const router = useRouter();
const params = useParams();
const statePasar = useProxy(pasarDesaState);
useShallowEffect(() => {
statePasar.pasarDesa.findUnique.load(params?.id as string);
}, []);
const data = statePasar.pasarDesa.findUnique.data;
if (!data) {
return (
<Stack py={10}>
<Skeleton height={400} radius="md" />
</Stack>
);
}
return (
<Box py={20}>
{/* Tombol kembali */}
<Box px={{ base: 'md', md: 100 }}>
<Button
variant="subtle"
onClick={() => router.back()}
leftSection={<IconArrowBack size={20} color={colors['blue-button']} />}
mb={15}
>
<Text fz={{ base: 'md', md: 'lg' }} lh={1.5}>
Kembali ke daftar produk
</Text>
</Button>
</Box>
<Paper
w={{ base: '100%', md: '70%' }}
mx="auto"
p="lg"
radius="md"
shadow="sm"
bg={colors['white-1']}
>
<Stack gap="lg">
{/* Gambar Produk */}
{data.image?.link ? (
<Image
src={data.image.link}
alt={data.nama}
radius="md"
h={250}
w="100%"
fit="cover"
loading="lazy"
/>
) : (
<Box
h={300}
bg="gray.1"
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 'var(--mantine-radius-md)' }}
>
<Text c="dimmed" fz={{ base: 'sm', md: 'md' }} lh={1.5}>
Tidak ada gambar
</Text>
</Box>
)}
{/* Detail Produk */}
<Stack gap="xs">
<Title order={2} lh={1.1} c={colors['blue-button']}>
{data.nama || 'Produk Tanpa Nama'}
</Title>
<Group>
<Badge color="green" size="lg" radius="md">
<Text c={"white"} fz={{ base: 'sm', md: 'md' }} fw={600} lh={1.4}>
Rp {data.harga?.toLocaleString('id-ID')}
</Text>
</Badge>
{data.rating && (
<Group gap={4}>
<IconStar size={18} color="#FFD43B" />
<Text fz={{ base: 'sm', md: 'md' }} fw={500} lh={1.5}>
{data.rating}
</Text>
</Group>
)}
</Group>
</Stack>
<Divider my="sm" />
{/* Info Tambahan */}
<Stack gap="sm">
<Box>
<Title order={3} lh={1.15}>
Kategori
</Title>
<Group gap="xs" mt={4}>
{data.KategoriToPasar && data.KategoriToPasar.length > 0 ? (
data.KategoriToPasar.map((kategori) => (
<Badge key={kategori.id} color="blue" variant="light" fz={{ base: 'xs', md: 'sm' }} lh={1.4}>
{kategori.kategori.nama}
</Badge>
))
) : (
<Text fz={{ base: 'xs', md: 'sm' }} c="dimmed" lh={1.5}>
Tidak ada kategori
</Text>
)}
</Group>
</Box>
{data.alamatUsaha && (
<Group gap={6}>
<IconMapPin size={18} color={colors['blue-button']} />
<Text fz={{ base: 'sm', md: 'md' }} lh={1.5}>
{data.alamatUsaha}
</Text>
</Group>
)}
{data.kontak && (
<Group gap={6}>
<IconPhone size={18} color={colors['blue-button']} />
<Text fz={{ base: 'sm', md: 'md' }} lh={1.5}>
{data.kontak}
</Text>
</Group>
)}
</Stack>
<Divider my="sm" />
{/* Deskripsi */}
<Box>
<Title order={3} lh={1.15}>
Deskripsi Produk
</Title>
<Text fz={{ base: 'sm', md: 'md' }} c="dimmed" mt={4} lh={1.5}>
Tidak ada deskripsi.
</Text>
</Box>
{/* Tombol Aksi User */}
{data.kontak && (
<Button
mt="md"
color="green"
size="lg"
radius="md"
component="a"
href={`https://wa.me/${data.kontak.replace(/[^0-9]/g, '')}`}
target="_blank"
leftSection={<IconBrandWhatsapp/>}
>
<Text c={"white"} fz={{ base: 'sm', md: 'md' }} lh={1.5}>
Hubungi Penjual via WhatsApp
</Text>
</Button>
)}
</Stack>
</Paper>
</Box>
);
}
export default DetailProdukPasarUser;

View File

@@ -1,14 +1,15 @@
'use client'
import umkmState from '@/app/admin/(dashboard)/_state/ekonomi/umkm/umkm';
import colors from '@/con/colors';
import { Box, Card, Flex, Grid, GridCol, Image, Paper, Skeleton, Stack, Text, Title, Badge, SimpleGrid, Group, Divider, Button, Center } from '@mantine/core';
import { Box, Card, Flex, Grid, GridCol, Image, Skeleton, Stack, Text, Title, Badge, SimpleGrid, Group, Divider, Button, Center } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconBrandWhatsapp, IconMapPinFilled, IconPackage, IconUser } from '@tabler/icons-react';
import { useParams } from 'next/navigation';
import { IconBrandWhatsapp, IconMapPinFilled, IconUser } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useProxy } from 'valtio/utils';
import BackButton from '../../../desa/layanan/_com/BackButto';
function Page() {
const router = useRouter();
const params = useParams();
const id = params.id as string;
const state = useProxy(umkmState.umkm.findUnique);
@@ -83,7 +84,14 @@ function Page() {
<Title order={2} mb="xl">Katalog Produk</Title>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg">
{u.produk?.map((p: any, k: number) => (
<Card key={k} withBorder padding="lg" radius="md">
<Card
key={k}
withBorder
padding="lg"
radius="md"
style={{ cursor: 'pointer' }}
onClick={() => router.push(`/darmasaba/ekonomi/umkm/produk/${p.id}`)}
>
<Card.Section>
<Image
src={p.image?.link || '/no-image.jpg'}

View File

@@ -1,5 +1,4 @@
'use client'
import pasarDesaState from '@/app/admin/(dashboard)/_state/ekonomi/pasar-desa/pasar-desa';
import umkmState from '@/app/admin/(dashboard)/_state/ekonomi/umkm/umkm';
import colors from '@/con/colors';
import {
@@ -9,9 +8,9 @@ import {
} from '@mantine/core';
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import {
IconBrandWhatsapp, IconMapPinFilled, IconSearch,
IconStarFilled, IconBuildingStore, IconUser,
IconShoppingBag, IconPackage
IconMapPinFilled, IconSearch,
IconBuildingStore, IconUser,
IconPackage
} from '@tabler/icons-react';
import { motion } from 'motion/react';
import { useRouter } from 'next/navigation';
@@ -21,7 +20,7 @@ import BackButton from '../../desa/layanan/_com/BackButto';
function Page() {
const router = useRouter()
const [activeTab, setActiveTab] = useState<string | null>('produk-pasar');
const [activeTab, setActiveTab] = useState<string | null>('produk-umkm');
return (
<Stack pos="relative" bg={colors.Bg} py="xl" gap="22">
@@ -31,7 +30,7 @@ function Page() {
<Box px={{ base: 'md', md: 100 }}>
<Title order={1} c={colors["blue-button"]} fw="bold" lh={1.15} mb="md">
Pasar Desa & UMKM Darmasaba
UMKM & Produk Desa Darmasaba
</Title>
<Text ta="justify" fz={{ base: 'sm', md: 'md' }} lh={1.5} c="black" mb="xl">
Pusat informasi produk lokal dan direktori usaha warga Desa Darmasaba.
@@ -40,21 +39,14 @@ function Page() {
<Tabs value={activeTab} onChange={setActiveTab} color="blue" variant="pills" radius="md">
<Tabs.List mb="xl">
<Tabs.Tab value="produk-pasar" leftSection={<IconShoppingBag size={18} />}>
Produk Pasar Desa
</Tabs.Tab>
<Tabs.Tab value="produk-umkm" leftSection={<IconPackage size={18} />}>
Produk UMKM
Katalog Produk
</Tabs.Tab>
<Tabs.Tab value="direktori-umkm" leftSection={<IconBuildingStore size={18} />}>
Direktori UMKM
Direktori Bisnis
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="produk-pasar">
<TabProdukPasar router={router} />
</Tabs.Panel>
<Tabs.Panel value="produk-umkm">
<TabProdukUmkm router={router} />
</Tabs.Panel>
@@ -70,96 +62,6 @@ function Page() {
// --- TAB COMPONENTS ---
function TabProdukPasar({ router }: { router: any }) {
const state = useProxy(pasarDesaState.pasarDesa);
const [search, setSearch] = useState('');
const [debouncedSearch] = useDebouncedValue(search, 1000);
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const { data, page, loading, totalPages } = state.findMany;
useShallowEffect(() => {
pasarDesaState.kategoriProduk.findManyAll.load();
}, []);
useShallowEffect(() => {
pasarDesaState.pasarDesa.findMany.load(page, 8, debouncedSearch, selectedCategory || undefined);
}, [page, debouncedSearch, selectedCategory]);
return (
<Stack gap="lg">
<Grid gutter="md" align="flex-end">
<GridCol span={{ base: 12, md: 8 }}>
<TextInput
label="Cari Produk Pasar"
placeholder="Ketik nama produk..."
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
leftSection={<IconSearch size={18} />}
radius="md"
/>
</GridCol>
<GridCol span={{ base: 12, md: 4 }}>
<Select
label="Kategori"
placeholder="Pilih Kategori"
data={pasarDesaState.kategoriProduk.findManyAll.data?.map((v) => ({
value: v.id, label: v.nama
})) || []}
value={selectedCategory}
onChange={setSelectedCategory}
clearable
radius="md"
/>
</GridCol>
</Grid>
{loading ? (
<Skeleton h={400} radius="md" />
) : (
<>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg">
{data?.map((v, k) => (
<motion.div
key={k}
onClick={() => router.push(`/darmasaba/ekonomi/pasar-desa/${v.id}`)}
whileHover={{ scale: 1.03 }}
>
<Paper p="md" radius="md" withBorder shadow="xs" style={{ cursor: 'pointer' }}>
<Image
radius="md"
src={v.image?.link || '/no-image.jpg'}
alt={v.nama}
h={180}
w="100%"
style={{ objectFit: 'cover' }}
/>
<Text mt="md" fw="bold" fz="lg" lineClamp={1}>{v.nama}</Text>
<Text c="blue" fw={700} fz="md">Rp {v.harga.toLocaleString('id-ID')}</Text>
<Flex py="xs" gap="xs" align="center">
<IconStarFilled size={16} color="#EBCB09" />
<Text size="sm">{v.rating}</Text>
</Flex>
<Flex justify="space-between" align="center">
<Flex gap={5} align="center">
<IconMapPinFilled size={16} color="red" />
<Text size="xs" c="dimmed" lineClamp={1}>{v.alamatUsaha}</Text>
</Flex>
<IconBrandWhatsapp size={18} color="green" />
</Flex>
</Paper>
</motion.div>
))}
</SimpleGrid>
<Center>
<Pagination total={totalPages} value={page} onChange={(p) => pasarDesaState.pasarDesa.findMany.load(p)} radius="md" />
</Center>
</>
)}
</Stack>
);
}
function TabProdukUmkm({ router }: { router: any }) {
const state = useProxy(umkmState.produk);
const [search, setSearch] = useState('');
@@ -210,7 +112,12 @@ function TabProdukUmkm({ router }: { router: any }) {
<>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg">
{data?.map((v, k) => (
<motion.div key={k} whileHover={{ scale: 1.03 }}>
<motion.div
key={k}
whileHover={{ scale: 1.03 }}
onClick={() => router.push(`/darmasaba/ekonomi/umkm/produk/${v.id}`)}
style={{ cursor: 'pointer' }}
>
<Card shadow="xs" padding="md" radius="md" withBorder>
<Card.Section>
<Image
@@ -224,7 +131,10 @@ function TabProdukUmkm({ router }: { router: any }) {
{v.stok > 0 ? 'Tersedia' : 'Habis'}
</Badge>
<Text fw={700} fz="md" lineClamp={1}>{v.nama}</Text>
<Text size="sm" c="blue" fw={500} style={{ cursor: 'pointer' }} onClick={() => router.push(`/darmasaba/ekonomi/umkm/${v.umkmId}`)}>
<Text size="sm" c="blue" fw={500} style={{ cursor: 'pointer' }} onClick={(e) => {
e.stopPropagation();
router.push(`/darmasaba/ekonomi/umkm/${v.umkmId}`);
}}>
{v.umkm?.nama}
</Text>
<Text fz="lg" fw={800} c="orange">Rp {v.harga.toLocaleString('id-ID')}</Text>

View File

@@ -0,0 +1,134 @@
'use client'
import colors from '@/con/colors';
import { Box, Button, Paper, Stack, Text, Image, Skeleton, Group, Badge, Divider, Title } from '@mantine/core';
import { IconArrowBack, IconBrandWhatsapp, IconMapPin, IconUser } from '@tabler/icons-react';
import { useRouter, useParams } from 'next/navigation';
import React from 'react';
import { useProxy } from 'valtio/utils';
import { useShallowEffect } from '@mantine/hooks';
import umkmState from '@/app/admin/(dashboard)/_state/ekonomi/umkm/umkm';
function DetailProdukPasarUser() {
const router = useRouter();
const params = useParams();
const state = useProxy(umkmState.produk.findUnique);
useShallowEffect(() => {
state.load(params?.id as string);
}, []);
const data = state.data;
if (state.loading || !data) {
return (
<Stack py={10} px={{ base: 'md', md: 100 }}>
<Skeleton height={400} radius="md" />
</Stack>
);
}
return (
<Box py={20} bg={colors.Bg}>
{/* Tombol kembali */}
<Box px={{ base: 'md', md: 100 }}>
<Button
variant="subtle"
onClick={() => router.push('/darmasaba/ekonomi/umkm')}
leftSection={<IconArrowBack size={20} color={colors['blue-button']} />}
mb={15}
>
<Text fz={{ base: 'md', md: 'lg' }} lh={1.5}>
Kembali ke Katalog
</Text>
</Button>
</Box>
<Paper
w={{ base: '90%', md: '70%' }}
mx="auto"
p="lg"
radius="md"
shadow="sm"
bg="white"
>
<Stack gap="lg">
{/* Gambar Produk */}
<Image
src={data.image?.link || '/no-image.jpg'}
alt={data.nama}
radius="md"
h={{ base: 250, md: 400 }}
w="100%"
fit="cover"
fallbackSrc="/no-image.jpg"
/>
{/* Detail Produk */}
<Stack gap="xs">
<Group justify="space-between" align="flex-start">
<Stack gap={5}>
<Badge color="blue" variant="light">{data.kategoriProduk?.nama}</Badge>
<Title order={1} fw={800} c={colors['blue-button']}>
{data.nama}
</Title>
</Stack>
<Badge color={data.stok > 0 ? 'green' : 'red'} size="lg">
{data.stok > 0 ? `Stok: ${data.stok}` : 'Stok Habis'}
</Badge>
</Group>
<Text fz="2rem" fw={900} c="orange">
Rp {data.harga?.toLocaleString('id-ID')}
</Text>
<Divider my="sm" />
<Stack gap="md">
<Box>
<Title order={3} mb="xs">Informasi Penjual</Title>
<Paper withBorder p="md" radius="md" bg="gray.0">
<Group justify="space-between">
<Stack gap={4}>
<Text fw={700} fz="lg" c="blue" style={{ cursor: 'pointer' }} onClick={() => router.push(`/darmasaba/ekonomi/umkm/${data.umkmId}`)}>
{data.umkm?.nama}
</Text>
<Group gap="xs">
<IconUser size={16} color="gray" />
<Text size="sm" c="dimmed">{data.umkm?.pemilik}</Text>
</Group>
<Group gap="xs">
<IconMapPin size={16} color="red" />
<Text size="sm" c="dimmed">{data.umkm?.alamat || 'Darmasaba'}</Text>
</Group>
</Stack>
{data.umkm?.kontak && (
<Button
color="green"
radius="md"
component="a"
href={`https://wa.me/${data.umkm.kontak.replace(/[^0-9]/g, '')}`}
target="_blank"
leftSection={<IconBrandWhatsapp size={20}/>}
>
WhatsApp
</Button>
)}
</Group>
</Paper>
</Box>
<Box>
<Title order={3} mb="xs">Deskripsi Produk</Title>
<Text fz="md" lh={1.6} c="dark">
{data.deskripsi || 'Tidak ada deskripsi tersedia untuk produk ini.'}
</Text>
</Box>
</Stack>
</Stack>
</Stack>
</Paper>
</Box>
);
}
export default DetailProdukPasarUser;

View File

@@ -172,8 +172,8 @@ const navbarListMenu = [
children: [
{
id: "5.1",
name: "Pasar Desa",
href: "/darmasaba/ekonomi/pasar-desa"
name: "UMKM",
href: "/darmasaba/ekonomi/umkm"
},
{
id: "5.2",