diff --git a/app/(admin)/admin/content-website/page.tsx b/app/(admin)/admin/content-website/page.tsx index adc8d97..1be1930 100644 --- a/app/(admin)/admin/content-website/page.tsx +++ b/app/(admin)/admin/content-website/page.tsx @@ -33,7 +33,11 @@ export default function ContentWebsitePage() { transition={{ duration: 0.3 }} >
- {levelId === "2" ? : } + {levelId === "3" ? ( + + ) : ( + + )}
); diff --git a/components/main/content-website-approver.tsx b/components/main/content-website-approver.tsx index bf874cc..c298b9e 100644 --- a/components/main/content-website-approver.tsx +++ b/components/main/content-website-approver.tsx @@ -1,149 +1,250 @@ "use client"; +import { useCallback, useEffect, useState } from "react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; -import { Eye, Pencil, Trash2, Filter } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Card, CardContent } from "@/components/ui/card"; +import { Filter, Loader2 } from "lucide-react"; +import { + approveCmsContentSubmission, + listCmsContentSubmissions, + rejectCmsContentSubmission, +} from "@/service/cms-content-submissions"; +import { apiPayload } from "@/service/cms-landing"; +import { formatDate } from "@/utils/global"; +import Swal from "sweetalert2"; + +const DOMAIN_LABEL: Record = { + hero: "Hero", + about: "About Us", + product: "Product", + service: "Service", + partner: "Partner", + popup: "Pop Up", +}; + +type Row = { + id: string; + domain: string; + title: string; + status: string; + submitter_name: string; + submitted_by_id: number; + payload: string; + created_at: string; +}; export default function ApproverContentWebsite() { - const tabs = [ - "Hero Section", - "About Us", - "Our Products", - "Our Services", - "Technology Partners", - "Pop Up", - ]; + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + const [actingId, setActingId] = useState(null); - const data = [ - { - title: "Beyond Expectations to Build Reputation.", - subtitle: "-", - author: "John Kontributor", - status: "Published", - date: "2024-01-15", - }, - { - title: "Manajemen Reputasi untuk Institusi", - subtitle: "-", - author: "Sarah Kontributor", - status: "Pending", - date: "2024-01-14", - }, - ]; + const load = useCallback(async () => { + setLoading(true); + try { + const res = await listCmsContentSubmissions({ + status: "pending", + page: 1, + limit: 100, + }); + const raw = apiPayload(res); + setRows(Array.isArray(raw) ? raw : []); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const filtered = rows.filter((r) => { + const q = search.trim().toLowerCase(); + if (!q) return true; + return ( + r.title.toLowerCase().includes(q) || + (r.submitter_name ?? "").toLowerCase().includes(q) || + (DOMAIN_LABEL[r.domain] ?? r.domain).toLowerCase().includes(q) + ); + }); + + async function onApprove(id: string) { + const ok = await Swal.fire({ + icon: "question", + title: "Terapkan perubahan ke website?", + showCancelButton: true, + }); + if (!ok.isConfirmed) return; + setActingId(id); + try { + const res = await approveCmsContentSubmission(id); + if ((res as { error?: boolean })?.error) { + await Swal.fire({ + icon: "error", + title: "Gagal", + text: String((res as { message?: unknown })?.message ?? ""), + }); + return; + } + await Swal.fire({ + icon: "success", + title: "Disetujui", + timer: 1600, + showConfirmButton: false, + }); + await load(); + } finally { + setActingId(null); + } + } + + async function onReject(id: string) { + const note = await Swal.fire({ + icon: "warning", + title: "Tolak pengajuan?", + input: "textarea", + inputPlaceholder: "Catatan (opsional)", + showCancelButton: true, + }); + if (!note.isConfirmed) return; + setActingId(id); + try { + const res = await rejectCmsContentSubmission( + id, + typeof note.value === "string" ? note.value : "", + ); + if ((res as { error?: boolean })?.error) { + await Swal.fire({ + icon: "error", + title: "Gagal", + text: String((res as { message?: unknown })?.message ?? ""), + }); + return; + } + await Swal.fire({ + icon: "success", + title: "Ditolak", + timer: 1400, + showConfirmButton: false, + }); + await load(); + } finally { + setActingId(null); + } + } return (
- {/* HEADER */}

Content Website

- Update homepage content, products, services, and partners + Tinjau pengajuan perubahan dari kontributor dan terapkan ke konten live.

- {/* TABS */} -
- {tabs.map((tab, i) => ( - - ))} -
- - {/* SEARCH & FILTER */} -
- -
- {/* TABLE */} -
- - - - - - - - - - - - - - {data.map((item, i) => ( - - - - - - - - - - - - - - ))} - -
Main TitleSubtitleAuthorStatusDateActions
- {item.title} - {item.subtitle}{item.author} - - {item.status} - - {item.date} -
- - - -
-
- - {/* FOOTER */} -
-

Showing 1 to 2 of 2 items

- -
- - - - - -
-
-
+ + + {loading ? ( +
+ +
+ ) : ( + + + + Judul + Bagian + Pengaju + Tanggal + Aksi + + + + {filtered.length === 0 ? ( + + + Tidak ada pengajuan tertunda. + + + ) : ( + filtered.map((item) => ( + + + {item.title} + + + + {DOMAIN_LABEL[item.domain] ?? item.domain} + + + + {item.submitter_name || `User #${item.submitted_by_id}`} + + + {formatDate(item.created_at)} + + + + + + + )) + )} + +
+ )} +
+
); } diff --git a/components/main/content-website.tsx b/components/main/content-website.tsx index a76c968..929a2a4 100644 --- a/components/main/content-website.tsx +++ b/components/main/content-website.tsx @@ -67,6 +67,7 @@ import { updatePopupNews, uploadPartnerLogo, } from "@/service/cms-landing"; +import { submitCmsContentSubmission } from "@/service/cms-content-submissions"; function revokeBlobRef(ref: MutableRefObject) { if (ref.current) { @@ -87,10 +88,19 @@ function setPickedFile( setter(file); } -export default function ContentWebsite() { +type ContentWebsiteProps = { + /** User level 2: changes go through approval instead of live CMS APIs. */ + contributorMode?: boolean; +}; + +export default function ContentWebsite({ + contributorMode = false, +}: ContentWebsiteProps) { const [activeTab, setActiveTab] = useState("hero"); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const [editMode, setEditMode] = useState(!contributorMode); + const canInteract = !contributorMode || editMode; const [heroId, setHeroId] = useState(null); const [heroImageId, setHeroImageId] = useState(null); @@ -257,6 +267,51 @@ export default function ContentWebsite() { await Swal.fire({ icon: "warning", title: "Main title is required" }); return; } + if (contributorMode && editMode) { + if (heroPendingFile) { + await Swal.fire({ + icon: "info", + title: "Gunakan URL gambar", + text: "Sebagai kontributor, unggah file tidak didukung. Salin URL dari Media Library ke bidang Image URL.", + }); + return; + } + setSaving(true); + try { + const res = await submitCmsContentSubmission({ + domain: "hero", + title: `Hero: ${heroPrimary.slice(0, 80)}`, + payload: { + hero_id: heroId ?? "", + hero_image_id: heroImageId ?? "", + primary_title: heroPrimary, + secondary_title: heroSecondary, + description: heroDesc, + primary_cta: heroCta1, + secondary_cta_text: heroCta2, + image_url: (heroRemoteUrl ?? "").trim(), + }, + }); + if ((res as { error?: boolean })?.error) { + await Swal.fire({ + icon: "error", + title: "Pengajuan gagal", + text: String((res as { message?: unknown })?.message ?? ""), + }); + return; + } + await Swal.fire({ + icon: "success", + title: "Diajukan untuk persetujuan", + text: "Perubahan Hero ada di My Content.", + timer: 2000, + showConfirmButton: false, + }); + } finally { + setSaving(false); + } + return; + } setSaving(true); try { const body = { @@ -316,6 +371,50 @@ export default function ContentWebsite() { await Swal.fire({ icon: "warning", title: "Main title is required" }); return; } + if (contributorMode && editMode) { + if (aboutPendingFile) { + await Swal.fire({ + icon: "info", + title: "Gunakan URL media", + text: "Sebagai kontributor, unggah file tidak didukung. Gunakan URL dari Media Library.", + }); + return; + } + setSaving(true); + try { + const res = await submitCmsContentSubmission({ + domain: "about", + title: `About: ${aboutPrimary.slice(0, 80)}`, + payload: { + about_id: aboutId ?? undefined, + about_media_image_id: aboutMediaImageId ?? undefined, + primary_title: aboutPrimary, + secondary_title: aboutSecondary, + description: aboutDesc, + primary_cta: aboutCta1, + secondary_cta_text: aboutCta2, + media_url: (aboutRemoteMediaUrl ?? "").trim(), + }, + }); + if ((res as { error?: boolean })?.error) { + await Swal.fire({ + icon: "error", + title: "Pengajuan gagal", + text: String((res as { message?: unknown })?.message ?? ""), + }); + return; + } + await Swal.fire({ + icon: "success", + title: "Diajukan untuk persetujuan", + timer: 2000, + showConfirmButton: false, + }); + } finally { + setSaving(false); + } + return; + } setSaving(true); try { const body: Record = { @@ -410,6 +509,51 @@ export default function ContentWebsite() { await Swal.fire({ icon: "warning", title: "Product title is required" }); return; } + if (contributorMode && editMode) { + if (productPendingFile) { + await Swal.fire({ + icon: "info", + title: "Gunakan URL gambar", + text: "Sebagai kontributor, gunakan URL dari Media Library untuk gambar produk.", + }); + return; + } + setSaving(true); + try { + const res = await submitCmsContentSubmission({ + domain: "product", + title: `Product: ${productPrimary.slice(0, 80)}`, + payload: { + product_id: productEditId ?? "", + product_image_id: productImageId ?? "", + primary_title: productPrimary, + secondary_title: productSecondary, + description: productDesc, + link_url: productLinkUrl, + image_url: (productRemoteUrl ?? "").trim(), + }, + }); + if ((res as { error?: boolean })?.error) { + await Swal.fire({ + icon: "error", + title: "Pengajuan gagal", + text: String((res as { message?: unknown })?.message ?? ""), + }); + return; + } + await Swal.fire({ + icon: "success", + title: "Diajukan untuk persetujuan", + timer: 1800, + showConfirmButton: false, + }); + beginEditProduct(null); + setProductModalOpen(false); + } finally { + setSaving(false); + } + return; + } setSaving(true); try { const body = { @@ -471,6 +615,38 @@ export default function ContentWebsite() { showCancelButton: true, }); if (!ok.isConfirmed) return; + if (contributorMode && editMode) { + setSaving(true); + try { + const res = await submitCmsContentSubmission({ + domain: "product", + title: `Delete product ${id}`, + payload: { action: "delete", product_id: id }, + }); + if ((res as { error?: boolean })?.error) { + await Swal.fire({ + icon: "error", + title: "Pengajuan gagal", + text: String((res as { message?: unknown })?.message ?? ""), + }); + return; + } + await Swal.fire({ + icon: "success", + title: "Penghapusan diajukan", + timer: 1600, + showConfirmButton: false, + }); + if (productEditId === id) { + beginEditProduct(null); + setProductModalOpen(false); + } + await loadAll(); + } finally { + setSaving(false); + } + return; + } setSaving(true); try { await deleteOurProductContent(id); @@ -524,6 +700,51 @@ export default function ContentWebsite() { await Swal.fire({ icon: "warning", title: "Service title is required" }); return; } + if (contributorMode && editMode) { + if (servicePendingFile) { + await Swal.fire({ + icon: "info", + title: "Gunakan URL gambar", + text: "Sebagai kontributor, gunakan URL dari Media Library untuk gambar layanan.", + }); + return; + } + setSaving(true); + try { + const res = await submitCmsContentSubmission({ + domain: "service", + title: `Service: ${servicePrimary.slice(0, 80)}`, + payload: { + service_id: serviceEditId ?? undefined, + service_image_id: serviceImageId ?? "", + primary_title: servicePrimary, + secondary_title: serviceSecondary, + description: serviceDesc, + link_url: serviceLinkUrl, + image_url: (serviceRemoteUrl ?? "").trim(), + }, + }); + if ((res as { error?: boolean })?.error) { + await Swal.fire({ + icon: "error", + title: "Pengajuan gagal", + text: String((res as { message?: unknown })?.message ?? ""), + }); + return; + } + await Swal.fire({ + icon: "success", + title: "Diajukan untuk persetujuan", + timer: 1800, + showConfirmButton: false, + }); + beginEditService(null); + setServiceModalOpen(false); + } finally { + setSaving(false); + } + return; + } setSaving(true); try { const body = { @@ -584,6 +805,38 @@ export default function ContentWebsite() { showCancelButton: true, }); if (!ok.isConfirmed) return; + if (contributorMode && editMode) { + setSaving(true); + try { + const res = await submitCmsContentSubmission({ + domain: "service", + title: `Delete service ${id}`, + payload: { action: "delete", service_id: id }, + }); + if ((res as { error?: boolean })?.error) { + await Swal.fire({ + icon: "error", + title: "Pengajuan gagal", + text: String((res as { message?: unknown })?.message ?? ""), + }); + return; + } + await Swal.fire({ + icon: "success", + title: "Penghapusan diajukan", + timer: 1600, + showConfirmButton: false, + }); + if (serviceEditId === id) { + beginEditService(null); + setServiceModalOpen(false); + } + await loadAll(); + } finally { + setSaving(false); + } + return; + } setSaving(true); try { await deleteOurServiceContent(id); @@ -630,6 +883,48 @@ export default function ContentWebsite() { await Swal.fire({ icon: "warning", title: "Partner name is required" }); return; } + if (contributorMode && editMode) { + if (partnerPendingFile) { + await Swal.fire({ + icon: "info", + title: "Gunakan URL logo", + text: "Sebagai kontributor, gunakan URL logo dari Media Library (isi URL gambar).", + }); + return; + } + setSaving(true); + try { + const res = await submitCmsContentSubmission({ + domain: "partner", + title: `Partner: ${partnerTitle.slice(0, 80)}`, + payload: { + partner_id: editingPartnerId ?? "", + primary_title: partnerTitle.trim(), + image_path: partnerStoredPath, + image_url: (partnerRemoteUrl ?? "").trim(), + }, + }); + if ((res as { error?: boolean })?.error) { + await Swal.fire({ + icon: "error", + title: "Pengajuan gagal", + text: String((res as { message?: unknown })?.message ?? ""), + }); + return; + } + await Swal.fire({ + icon: "success", + title: "Diajukan untuk persetujuan", + timer: 1800, + showConfirmButton: false, + }); + beginEditPartner(null); + setPartnerModalOpen(false); + } finally { + setSaving(false); + } + return; + } setSaving(true); try { const body = { @@ -686,6 +981,38 @@ export default function ContentWebsite() { showCancelButton: true, }); if (!ok.isConfirmed) return; + if (contributorMode && editMode) { + setSaving(true); + try { + const res = await submitCmsContentSubmission({ + domain: "partner", + title: `Delete partner ${id}`, + payload: { action: "delete", partner_id: id }, + }); + if ((res as { error?: boolean })?.error) { + await Swal.fire({ + icon: "error", + title: "Pengajuan gagal", + text: String((res as { message?: unknown })?.message ?? ""), + }); + return; + } + await Swal.fire({ + icon: "success", + title: "Penghapusan diajukan", + timer: 1600, + showConfirmButton: false, + }); + if (editingPartnerId === id) { + beginEditPartner(null); + setPartnerModalOpen(false); + } + await loadAll(); + } finally { + setSaving(false); + } + return; + } setSaving(true); try { await deletePartnerContent(id); @@ -738,6 +1065,51 @@ export default function ContentWebsite() { await Swal.fire({ icon: "warning", title: "Main title is required" }); return; } + if (contributorMode && editMode) { + if (popupPendingFile) { + await Swal.fire({ + icon: "info", + title: "Gunakan URL gambar", + text: "Sebagai kontributor, gunakan URL banner dari Media Library.", + }); + return; + } + setSaving(true); + try { + const res = await submitCmsContentSubmission({ + domain: "popup", + title: `Pop-up: ${popupPrimary.slice(0, 80)}`, + payload: { + popup_id: popupEditId ?? undefined, + primary_title: popupPrimary, + secondary_title: popupSecondary, + description: popupDesc, + primary_cta: popupCta1, + secondary_cta_text: popupCta2, + media_url: (popupRemoteUrl ?? "").trim(), + }, + }); + if ((res as { error?: boolean })?.error) { + await Swal.fire({ + icon: "error", + title: "Pengajuan gagal", + text: String((res as { message?: unknown })?.message ?? ""), + }); + return; + } + await Swal.fire({ + icon: "success", + title: "Diajukan untuk persetujuan", + timer: 1800, + showConfirmButton: false, + }); + beginEditPopup(null); + setPopupModalOpen(false); + } finally { + setSaving(false); + } + return; + } setSaving(true); try { const body = { @@ -803,6 +1175,38 @@ export default function ContentWebsite() { showCancelButton: true, }); if (!ok.isConfirmed) return; + if (contributorMode && editMode) { + setSaving(true); + try { + const res = await submitCmsContentSubmission({ + domain: "popup", + title: `Delete popup ${id}`, + payload: { action: "delete", popup_id: id }, + }); + if ((res as { error?: boolean })?.error) { + await Swal.fire({ + icon: "error", + title: "Pengajuan gagal", + text: String((res as { message?: unknown })?.message ?? ""), + }); + return; + } + await Swal.fire({ + icon: "success", + title: "Penghapusan diajukan", + timer: 1600, + showConfirmButton: false, + }); + if (popupEditId === id) { + beginEditPopup(null); + setPopupModalOpen(false); + } + await loadAll(); + } finally { + setSaving(false); + } + return; + } setSaving(true); try { const res = await deletePopupNews(id); @@ -852,13 +1256,32 @@ export default function ContentWebsite() { Preview - + {contributorMode ? ( + + ) : null} + {contributorMode && !editMode ? ( +

+ Aktifkan Edit Mode untuk mengusulkan perubahan. Perubahan akan masuk ke{" "} + My Content menunggu persetujuan approver. Unggah file gambar tidak tersedia sebagai kontributor; + gunakan URL dari Media Library pada bidang yang disediakan. +

+ ) : null} + +
@@ -922,18 +1345,30 @@ export default function ContentWebsite() {

- Upload a JPG, PNG, GIF, or WebP file. Stored in MinIO and shown on the landing hero. + {contributorMode + ? "Tempel URL gambar dari Media Library (kontributor tidak dapat mengunggah file langsung)." + : "Upload a JPG, PNG, GIF, or WebP file. Stored in MinIO and shown on the landing hero."}

- { - const f = e.target.files?.[0] ?? null; - setPickedFile(f, heroBlobUrlRef, setHeroPendingFile); - e.target.value = ""; - }} - /> + {contributorMode ? ( + setHeroRemoteUrl(e.target.value)} + /> + ) : ( + { + const f = e.target.files?.[0] ?? null; + setPickedFile(f, heroBlobUrlRef, setHeroPendingFile); + e.target.value = ""; + }} + /> + )} {heroBlobUrlRef.current || heroRemoteUrl ? (
{/* eslint-disable-next-line @next/next/no-img-element */} @@ -1015,18 +1450,30 @@ export default function ContentWebsite() {

- Upload JPG/PNG/GIF/WebP or MP4/WebM. Stored in MinIO; shown inside the phone mockup on the landing page. + {contributorMode + ? "Tempel URL gambar atau video dari Media Library." + : "Upload JPG/PNG/GIF/WebP or MP4/WebM. Stored in MinIO; shown inside the phone mockup on the landing page."}

- { - const f = e.target.files?.[0] ?? null; - setPickedFile(f, aboutBlobUrlRef, setAboutPendingFile); - e.target.value = ""; - }} - /> + {contributorMode ? ( + setAboutRemoteMediaUrl(e.target.value)} + /> + ) : ( + { + const f = e.target.files?.[0] ?? null; + setPickedFile(f, aboutBlobUrlRef, setAboutPendingFile); + e.target.value = ""; + }} + /> + )} {aboutBlobUrlRef.current || aboutRemoteMediaUrl ? (
{aboutPendingFile?.type.startsWith("video/") || @@ -1253,16 +1700,26 @@ export default function ContentWebsite() {
- { - const f = e.target.files?.[0] ?? null; - setPickedFile(f, productBlobUrlRef, setProductPendingFile); - e.target.value = ""; - }} - /> + {contributorMode ? ( + setProductRemoteUrl(e.target.value)} + /> + ) : ( + { + const f = e.target.files?.[0] ?? null; + setPickedFile(f, productBlobUrlRef, setProductPendingFile); + e.target.value = ""; + }} + /> + )} {productBlobUrlRef.current || productRemoteUrl ? (
{/* eslint-disable-next-line @next/next/no-img-element */} @@ -1433,16 +1890,26 @@ export default function ContentWebsite() {
- { - const f = e.target.files?.[0] ?? null; - setPickedFile(f, serviceBlobUrlRef, setServicePendingFile); - e.target.value = ""; - }} - /> + {contributorMode ? ( + setServiceRemoteUrl(e.target.value)} + /> + ) : ( + { + const f = e.target.files?.[0] ?? null; + setPickedFile(f, serviceBlobUrlRef, setServicePendingFile); + e.target.value = ""; + }} + /> + )} {serviceBlobUrlRef.current || serviceRemoteUrl ? (
{/* eslint-disable-next-line @next/next/no-img-element */} @@ -1583,16 +2050,26 @@ export default function ContentWebsite() {
- setPartnerRemoteUrl(e.target.value)} + /> + ) : ( + { const f = e.target.files?.[0] ?? null; setPickedFile(f, partnerBlobUrlRef, setPartnerPendingFile); e.target.value = ""; }} /> + )} {partnerBlobUrlRef.current || partnerRemoteUrl ? (
{/* eslint-disable-next-line @next/next/no-img-element */} @@ -1766,16 +2243,26 @@ export default function ContentWebsite() { - { - const f = e.target.files?.[0] ?? null; - setPickedFile(f, popupBlobUrlRef, setPopupPendingFile); - e.target.value = ""; - }} - /> + {contributorMode ? ( + setPopupRemoteUrl(e.target.value)} + /> + ) : ( + { + const f = e.target.files?.[0] ?? null; + setPickedFile(f, popupBlobUrlRef, setPopupPendingFile); + e.target.value = ""; + }} + /> + )} {popupBlobUrlRef.current || popupRemoteUrl ? (
{/* eslint-disable-next-line @next/next/no-img-element */} @@ -1810,6 +2297,7 @@ export default function ContentWebsite() { +
); } diff --git a/components/main/my-content.tsx b/components/main/my-content.tsx index f86bb02..81bb57b 100644 --- a/components/main/my-content.tsx +++ b/components/main/my-content.tsx @@ -4,95 +4,283 @@ import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { Search, Filter } from "lucide-react"; -import Image from "next/image"; -import { useState } from "react"; +import { Search, Filter, ChevronLeft, ChevronRight } from "lucide-react"; +import Link from "next/link"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import Cookies from "js-cookie"; +import { formatDate } from "@/utils/global"; +import { listCmsContentSubmissions } from "@/service/cms-content-submissions"; +import { getArticlesForMyContent } from "@/service/article"; +import { apiPayload } from "@/service/cms-landing"; +import { Loader2 } from "lucide-react"; -const stats = [ - { title: "Total Content", value: 24, color: "bg-blue-500" }, - { title: "Drafts", value: 8, color: "bg-slate-600" }, - { title: "Pending", value: 10, color: "bg-yellow-500" }, - { title: "Approved", value: 10, color: "bg-green-600" }, - { title: "Revision/Rejected", value: 6, color: "bg-red-600" }, -]; +const PLACEHOLDER_IMG = + "https://placehold.co/400x240/f1f5f9/64748b?text=Content"; -const contents = [ - { - id: 1, - title: "Bharatu Mardi Hadji Gugur Saat Bertugas...", - image: "/image/bharatu.jpg", - status: "Pending", - category: "News", - date: "2024-01-20", - }, - { - id: 2, - title: "Novita Hardini: Jangan Sampai Pariwisata...", - image: "/image/novita2.png", - status: "Approved", - category: "News", - date: "2024-01-20", - }, - { - id: 3, - title: "Lestari Moerdijat: Butuh Afirmasi...", - image: "/image/lestari2.png", - status: "Rejected", - category: "News", - date: "2024-01-20", - }, - { - id: 4, - title: "Lestari Moerdijat: Butuh Afirmasi...", - image: "/image/lestari2.png", - status: "Draft", - category: "News", - date: "2024-01-20", - }, -]; - -const getStatusStyle = (status: string) => { - switch (status) { - case "Pending Approval": - return "bg-yellow-100 text-yellow-700 border border-yellow-200"; - - case "Approved": - return "bg-green-100 text-green-700 border border-green-200"; - - case "Rejected": - return "bg-red-100 text-red-700 border border-red-200"; - - case "Draft": - return "bg-slate-100 text-slate-600 border border-slate-200"; - - default: - return ""; - } +type CmsRow = { + id: string; + domain: string; + title: string; + status: string; + submitter_name: string; + submitted_by_id: number; + created_at: string; }; +type ArticleRow = { + id: number; + title: string; + thumbnailUrl?: string; + isDraft?: boolean; + isPublish?: boolean; + publishStatus?: string; + statusId?: number | null; + createdAt?: string; + created_at?: string; + createdByName?: string; +}; + +type UnifiedStatus = "draft" | "pending" | "approved" | "rejected"; + +type UnifiedItem = { + key: string; + source: "website" | "news"; + title: string; + thumb: string; + status: UnifiedStatus; + statusLabel: string; + date: string; + href: string; +}; + +function cmsToUnified(r: CmsRow): UnifiedItem { + const st = (r.status || "").toLowerCase(); + let status: UnifiedStatus = "pending"; + let statusLabel = "Pending Approval"; + if (st === "approved") { + status = "approved"; + statusLabel = "Approved"; + } else if (st === "rejected") { + status = "rejected"; + statusLabel = "Rejected"; + } else if (st === "pending") { + status = "pending"; + statusLabel = "Pending Approval"; + } + return { + key: `cms-${r.id}`, + source: "website", + title: r.title, + thumb: PLACEHOLDER_IMG, + status, + statusLabel, + date: r.created_at, + href: "/admin/content-website", + }; +} + +function articleHistoryStatus(a: ArticleRow): UnifiedStatus { + if (a.isDraft) return "draft"; + if (a.isPublish) return "approved"; + const ps = (a.publishStatus || "").toLowerCase(); + if (ps.includes("reject")) return "rejected"; + if (a.statusId === 3) return "rejected"; + return "pending"; +} + +function articleStatusLabel(s: UnifiedStatus): string { + switch (s) { + case "draft": + return "Draft"; + case "pending": + return "Pending Approval"; + case "approved": + return "Approved"; + case "rejected": + return "Rejected"; + default: + return "Pending Approval"; + } +} + +function articleToUnified(r: ArticleRow): UnifiedItem { + const status = articleHistoryStatus(r); + const rawDate = r.createdAt ?? r.created_at ?? ""; + return { + key: `art-${r.id}`, + source: "news", + title: r.title || "Untitled", + thumb: r.thumbnailUrl?.trim() || PLACEHOLDER_IMG, + status, + statusLabel: articleStatusLabel(status), + date: rawDate, + href: `/admin/news-article/detail/${r.id}`, + }; +} + +function statusBadgeClass(status: UnifiedStatus): string { + switch (status) { + case "pending": + return "bg-yellow-100 text-yellow-800 border-yellow-200"; + case "approved": + return "bg-green-100 text-green-800 border-green-200"; + case "rejected": + return "bg-red-100 text-red-800 border-red-200"; + case "draft": + default: + return "bg-slate-100 text-slate-700 border-slate-200"; + } +} + +const PAGE_SIZE = 8; + export default function MyContent() { + const [levelId, setLevelId] = useState(); const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [sourceFilter, setSourceFilter] = useState<"all" | "news" | "website">( + "all", + ); + const [statusFilter, setStatusFilter] = useState< + "all" | UnifiedStatus + >("all"); + const [loading, setLoading] = useState(true); + const [cmsRows, setCmsRows] = useState([]); + const [articleRows, setArticleRows] = useState([]); + const [page, setPage] = useState(1); + + const isApprover = levelId === "3"; + const isContributor = levelId === "2"; + + useEffect(() => { + setLevelId(Cookies.get("ulne")); + }, []); + + useEffect(() => { + const t = setTimeout(() => setDebouncedSearch(search), 350); + return () => clearTimeout(t); + }, [search]); + + const load = useCallback(async () => { + if (levelId === undefined) return; + setLoading(true); + try { + const cmsMine = isContributor; + const cmsRes = await listCmsContentSubmissions({ + status: "all", + mine: cmsMine, + page: 1, + limit: 500, + }); + const cmsData = apiPayload(cmsRes); + setCmsRows(Array.isArray(cmsData) ? cmsData : []); + + const artMode = isApprover ? "approver" : "own"; + const artRes = await getArticlesForMyContent({ + mode: artMode, + page: 1, + limit: 500, + }); + const artData = apiPayload(artRes); + setArticleRows(Array.isArray(artData) ? artData : []); + } finally { + setLoading(false); + } + }, [levelId, isContributor, isApprover]); + + useEffect(() => { + load(); + }, [load]); + + const mergedAll = useMemo(() => { + const cms = cmsRows.map(cmsToUnified); + const arts = articleRows.map(articleToUnified); + let list = [...cms, ...arts]; + const q = search.trim().toLowerCase(); + if (q) { + list = list.filter( + (x) => + x.title.toLowerCase().includes(q) || + x.statusLabel.toLowerCase().includes(q), + ); + } + if (sourceFilter === "news") list = list.filter((x) => x.source === "news"); + if (sourceFilter === "website") + list = list.filter((x) => x.source === "website"); + if (statusFilter !== "all") + list = list.filter((x) => x.status === statusFilter); + list.sort((a, b) => (a.date < b.date ? 1 : -1)); + return list; + }, [cmsRows, articleRows, debouncedSearch, sourceFilter, statusFilter]); + + const stats = useMemo(() => { + const draft = mergedAll.filter((x) => x.status === "draft").length; + const pending = mergedAll.filter((x) => x.status === "pending").length; + const approved = mergedAll.filter((x) => x.status === "approved").length; + const rejected = mergedAll.filter((x) => x.status === "rejected").length; + return { + total: mergedAll.length, + draft, + pending, + approved, + rejected, + }; + }, [mergedAll]); + + const totalPages = Math.max(1, Math.ceil(mergedAll.length / PAGE_SIZE)); + const currentPage = Math.min(page, totalPages); + const pageItems = useMemo(() => { + const start = (currentPage - 1) * PAGE_SIZE; + return mergedAll.slice(start, start + PAGE_SIZE); + }, [mergedAll, currentPage]); + + useEffect(() => { + setPage(1); + }, [sourceFilter, statusFilter, debouncedSearch, levelId]); + + if (levelId === undefined) { + return ( +
+ +
+ ); + } return (
-

My Content

-

- Track all your content submissions and drafts +

My Content

+

+ Track all your content submissions and drafts. +

+

+ {isContributor + ? "Riwayat konten Anda: perubahan Content Website (setelah diajukan) dan artikel. Buka item untuk mengedit atau melanjutkan persetujuan di halaman masing-masing." + : isApprover + ? "Riwayat dari kontributor: Content Website dan News & Articles (tanpa draft). Persetujuan dilakukan di halaman Content Website atau detail artikel." + : "Ringkasan konten terkait akun Anda."}

- {stats.map((item, index) => ( - + {[ + { title: "Total Content", value: stats.total, color: "bg-blue-500" }, + { title: "Drafts", value: stats.draft, color: "bg-slate-600" }, + { title: "Pending", value: stats.pending, color: "bg-yellow-500" }, + { title: "Approved", value: stats.approved, color: "bg-green-600" }, + { + title: "Revision/Rejected", + value: stats.rejected, + color: "bg-red-600", + }, + ].map((item) => ( +
- {item.value} -
+ className={`w-12 h-12 rounded-xl flex items-center justify-center text-white shrink-0 ${item.color}`} + />
-

{item.value}

+

{item.value}

{item.title}

@@ -104,83 +292,136 @@ export default function MyContent() {
setSearch(e.target.value)} />
- - -
- -
- {contents.map((item) => ( - +
+ + +
+
+ +
+ +
+
- + +
+ ) : pageItems.length === 0 ? ( +

No content found.

+ ) : ( + <> +
+ {pageItems.map((item) => ( + + +
+ + {item.statusLabel} + + + {item.source === "news" + ? "News & Articles" + : "Content Website"} + +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ +

+ {item.title} +

+

+ {item.date ? formatDate(item.date) : "—"} +

+
+
+ + ))} +
+ +
+

+ Showing {(currentPage - 1) * PAGE_SIZE + 1} to{" "} + {Math.min(currentPage * PAGE_SIZE, mergedAll.length)} of{" "} + {mergedAll.length} items +

+
+ + + Page {currentPage} / {totalPages} + +
- -
- {item.title} -
- - - {/* TITLE */} -

- {item.title} -

- -

{item.date}

-
- - ))} -
- -
- - - - - - - - - -
+
+ + )}
); } diff --git a/service/article.ts b/service/article.ts index c212ee5..37d5346 100644 --- a/service/article.ts +++ b/service/article.ts @@ -66,6 +66,44 @@ export async function getArticlePagination(props: PaginationRequest) { ); } +/** Articles in the approval queue for the current user level (requires auth). */ +export async function getArticlesPendingApproval(props: { + page?: number; + limit?: number; + typeId?: number; +}) { + const page = props.page ?? 1; + const limit = props.limit ?? 20; + const typeParam = + props.typeId !== undefined && props.typeId !== null + ? `&typeId=${props.typeId}` + : ""; + return await httpGetInterceptor( + `/articles/pending-approval?page=${page}&limit=${limit}${typeParam}`, + ); +} + +/** + * My Content history: own = all articles by current user; approver = non-draft + * articles created by contributors (user level 2). Requires auth. + */ +export async function getArticlesForMyContent(props: { + mode: "own" | "approver"; + page?: number; + limit?: number; + title?: string; +}) { + const page = props.page ?? 1; + const limit = props.limit ?? 200; + const titleQ = + props.title && props.title.trim() + ? `&title=${encodeURIComponent(props.title.trim())}` + : ""; + return await httpGetInterceptor( + `/articles?myContentMode=${props.mode}&page=${page}&limit=${limit}&sort=desc&sortBy=created_at${titleQ}`, + ); +} + export async function getTopArticles(props: PaginationRequest) { const { page, limit, search, startDate, endDate, isPublish, category } = props; diff --git a/service/cms-content-submissions.ts b/service/cms-content-submissions.ts new file mode 100644 index 0000000..bf85928 --- /dev/null +++ b/service/cms-content-submissions.ts @@ -0,0 +1,42 @@ +import { + httpGetInterceptor, + httpPostInterceptor, +} from "./http-config/http-interceptor-services"; + +export async function submitCmsContentSubmission(body: { + domain: string; + title: string; + payload: Record; +}) { + return httpPostInterceptor("/cms-content-submissions", body); +} + +export async function listCmsContentSubmissions(params?: { + /** Omit or `all` for every status (history). */ + status?: string; + mine?: boolean; + page?: number; + limit?: number; +}) { + const q = new URLSearchParams(); + if (params?.status != null && params.status !== "") { + q.set("status", params.status); + } + if (params?.mine) q.set("mine", "1"); + if (params?.page) q.set("page", String(params.page)); + if (params?.limit) q.set("limit", String(params.limit)); + const qs = q.toString(); + return httpGetInterceptor( + `/cms-content-submissions${qs ? `?${qs}` : ""}`, + ); +} + +export async function approveCmsContentSubmission(id: string) { + return httpPostInterceptor(`/cms-content-submissions/${id}/approve`, {}); +} + +export async function rejectCmsContentSubmission(id: string, note?: string) { + return httpPostInterceptor(`/cms-content-submissions/${id}/reject`, { + note: note ?? "", + }); +}