This commit is contained in:
Flatlogic Bot 2026-05-06 11:16:57 +00:00
parent 5f811c50b4
commit 2130d23ff4
12 changed files with 2713 additions and 160 deletions

View File

@ -0,0 +1,14 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('page_access_rules', 'userId', {
type: Sequelize.DataTypes.UUID,
allowNull: true,
});
},
async down(queryInterface) {
await queryInterface.removeColumn('page_access_rules', 'userId');
},
};

View File

@ -1,9 +1,3 @@
const config = require('../../config');
const providers = config.providers;
const crypto = require('crypto');
const bcrypt = require('bcrypt');
const moment = require('moment');
module.exports = function(sequelize, DataTypes) {
const page_access_rules = sequelize.define(
'page_access_rules',
@ -98,6 +92,13 @@ effective_until: {
constraints: false,
});
db.page_access_rules.belongsTo(db.users, {
as: 'user',
foreignKey: {
name: 'userId',
},
constraints: false,
});

View File

@ -2,7 +2,6 @@ const config = require('../../config');
const providers = config.providers;
const crypto = require('crypto');
const bcrypt = require('bcrypt');
const moment = require('moment');
module.exports = function(sequelize, DataTypes) {
const users = sequelize.define(
@ -154,6 +153,14 @@ provider: {
constraints: false,
});
db.users.hasMany(db.page_access_rules, {
as: 'page_access_rules_user',
foreignKey: {
name: 'userId',
},
constraints: false,
});
//end loop
@ -191,8 +198,8 @@ provider: {
};
users.beforeCreate((users, options) => {
users = trimStringFields(users);
users.beforeCreate((users) => {
trimStringFields(users);
if (users.provider !== providers.LOCAL && Object.values(providers).indexOf(users.provider) > -1) {
users.emailVerified = true;
@ -212,8 +219,8 @@ provider: {
}
});
users.beforeUpdate((users, options) => {
users = trimStringFields(users);
users.beforeUpdate((users) => {
trimStringFields(users);
});

View File

@ -6,7 +6,6 @@ const passport = require('passport');
const path = require('path');
const fs = require('fs');
const bodyParser = require('body-parser');
const db = require('./db/models');
const config = require('./config');
const swaggerUI = require('swagger-ui-express');
const swaggerJsDoc = require('swagger-jsdoc');
@ -18,8 +17,7 @@ const sqlRoutes = require('./routes/sql');
const pexelsRoutes = require('./routes/pexels');
const openaiRoutes = require('./routes/openai');
const portalAccessRoutes = require('./routes/portalAccess');
const usersRoutes = require('./routes/users');
@ -88,6 +86,7 @@ app.use(bodyParser.json());
app.use('/api/auth', authRoutes);
app.use('/api/file', fileRoutes);
app.use('/api/pexels', pexelsRoutes);
app.use('/api/portal-access', portalAccessRoutes);
app.enable('trust proxy');

View File

@ -0,0 +1,142 @@
const express = require('express');
const passport = require('passport');
const PortalAccessService = require('../services/portalAccess');
const wrapAsync = require('../helpers').wrapAsync;
const commonErrorHandler = require('../helpers').commonErrorHandler;
const { checkPermissions } = require('../middlewares/check-permissions');
const router = express.Router();
function getViewerSessionToken(req) {
const sessionHeader = req.headers['x-viewer-session'];
if (Array.isArray(sessionHeader)) {
return sessionHeader[0];
}
return sessionHeader;
}
router.post('/login', wrapAsync(async (req, res) => {
const payload = await PortalAccessService.login(
req.body?.name,
req.body?.uniqueNumber,
);
res.status(200).send(payload);
}));
router.get('/session', wrapAsync(async (req, res) => {
const payload = await PortalAccessService.getViewerSession(
getViewerSessionToken(req),
);
res.set('Cache-Control', 'no-store');
res.status(200).send(payload);
}));
router.get('/document', wrapAsync(async (req, res) => {
const requestBaseUrl = `${req.protocol}://${req.get('host')}`;
const payload = await PortalAccessService.getViewerDocumentBuffer(
getViewerSessionToken(req),
requestBaseUrl,
);
const safeFilename = (payload.filename || 'document.pdf').replace(/"/g, '');
res.set('Cache-Control', 'no-store');
res.set('Content-Type', 'application/pdf');
res.set('Content-Disposition', `inline; filename="${safeFilename}"`);
res.status(200).send(payload.buffer);
}));
router.get('/feedback', wrapAsync(async (req, res) => {
const payload = await PortalAccessService.listViewerFeedback(
getViewerSessionToken(req),
);
res.set('Cache-Control', 'no-store');
res.status(200).send(payload);
}));
router.post('/feedback', wrapAsync(async (req, res) => {
const payload = await PortalAccessService.createViewerFeedback(
getViewerSessionToken(req),
req.body?.message,
);
res.status(200).send(payload);
}));
router.use(passport.authenticate('jwt', { session: false }));
router.get(
'/viewers',
checkPermissions('CREATE_USERS'),
wrapAsync(async (req, res) => {
const payload = await PortalAccessService.listManagedViewers();
res.status(200).send(payload);
}),
);
router.post(
'/viewers',
checkPermissions('CREATE_USERS'),
wrapAsync(async (req, res) => {
const payload = await PortalAccessService.createManagedViewer(
req.body,
req.currentUser,
);
res.status(200).send(payload);
}),
);
router.put(
'/viewers/:id',
checkPermissions('UPDATE_USERS'),
wrapAsync(async (req, res) => {
const payload = await PortalAccessService.updateManagedViewer(
req.params.id,
req.body,
req.currentUser,
);
res.status(200).send(payload);
}),
);
router.delete(
'/viewers/:id',
checkPermissions('DELETE_USERS'),
wrapAsync(async (req, res) => {
await PortalAccessService.removeManagedViewer(req.params.id, req.currentUser);
res.status(200).send(true);
}),
);
router.post(
'/assignments',
checkPermissions('CREATE_PAGE_ACCESS_RULES'),
wrapAsync(async (req, res) => {
const payload = await PortalAccessService.upsertAssignment(
req.body,
req.currentUser,
);
res.status(200).send(payload);
}),
);
router.delete(
'/assignments/:viewerId',
checkPermissions('UPDATE_PAGE_ACCESS_RULES'),
wrapAsync(async (req, res) => {
await PortalAccessService.clearAssignment(
req.params.viewerId,
req.currentUser,
);
res.status(200).send(true);
}),
);
router.use('/', commonErrorHandler);
module.exports = router;

View File

@ -0,0 +1,741 @@
const axios = require('axios');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { Op, Sequelize } = require('sequelize');
const db = require('../db/models');
const config = require('../config');
const VIEWER_PROVIDER = 'viewer_access';
const VIEWER_TOKEN_EXPIRY = '4h';
function buildHttpError(message, code = 400) {
const error = new Error(message);
error.code = code;
return error;
}
function normalizeValue(value) {
return String(value || '').trim();
}
function normalizeName(value) {
return normalizeValue(value).replace(/\s+/g, ' ');
}
function buildViewerEmail(uniqueNumber) {
const normalizedUniqueNumber = normalizeValue(uniqueNumber);
const encodedUniqueNumber = Buffer.from(normalizedUniqueNumber)
.toString('hex')
.slice(0, 40) || Date.now().toString(16);
return `viewer-${encodedUniqueNumber}@secure-pdf.local`;
}
function serializePdfDocument(pdfDocument) {
if (!pdfDocument) {
return null;
}
const rawPdfDocument = pdfDocument.get ? pdfDocument.get({ plain: true }) : pdfDocument;
const pdfSourceFile = Array.isArray(rawPdfDocument.pdf_file)
? rawPdfDocument.pdf_file[0]
: null;
return {
id: rawPdfDocument.id,
title: rawPdfDocument.title,
fileName: rawPdfDocument.file_name,
totalPages: rawPdfDocument.total_pages,
isActive: rawPdfDocument.is_active,
publishedAt: rawPdfDocument.published_at,
hasSourceFile: Boolean(pdfSourceFile),
sourceFileName: pdfSourceFile?.name || rawPdfDocument.file_name || null,
};
}
function serializeAssignment(assignment) {
if (!assignment) {
return null;
}
const rawAssignment = assignment.get ? assignment.get({ plain: true }) : assignment;
return {
id: rawAssignment.id,
ruleName: rawAssignment.rule_name,
pageNumber: rawAssignment.page_number,
isEnabled: rawAssignment.is_enabled,
effectiveFrom: rawAssignment.effective_from,
effectiveUntil: rawAssignment.effective_until,
pdfDocument: serializePdfDocument(rawAssignment.pdf_document),
};
}
function serializeViewer(viewer, assignment = null) {
if (!viewer) {
return null;
}
const rawViewer = viewer.get ? viewer.get({ plain: true }) : viewer;
return {
id: rawViewer.id,
name: rawViewer.firstName,
uniqueNumber: rawViewer.phoneNumber,
disabled: rawViewer.disabled,
createdAt: rawViewer.createdAt,
updatedAt: rawViewer.updatedAt,
assignment,
};
}
async function getViewerRole(transaction) {
const viewerRole = await db.roles.findOne({
where: { name: config.roles.user || 'Document Viewer' },
transaction,
});
if (!viewerRole) {
throw buildHttpError('Role Document Viewer tidak ditemukan.', 500);
}
return viewerRole;
}
function buildViewerToken(userId) {
return jwt.sign(
{
viewerSession: {
userId,
provider: VIEWER_PROVIDER,
},
},
config.secret_key,
{ expiresIn: VIEWER_TOKEN_EXPIRY },
);
}
function verifyViewerToken(token) {
if (!token) {
throw buildHttpError('Sesi viewer tidak ditemukan. Silakan login ulang.', 401);
}
try {
const payload = jwt.verify(token, config.secret_key);
if (
!payload?.viewerSession?.userId ||
payload.viewerSession.provider !== VIEWER_PROVIDER
) {
throw buildHttpError('Sesi viewer tidak valid.', 401);
}
return payload.viewerSession;
} catch (error) {
if (error.code) {
throw error;
}
throw buildHttpError('Sesi viewer tidak valid atau sudah kedaluwarsa.', 401);
}
}
async function findManagedViewerById(id, transaction) {
const viewer = await db.users.findOne({
where: {
id,
provider: VIEWER_PROVIDER,
},
transaction,
});
if (!viewer) {
throw buildHttpError('Data viewer tidak ditemukan.', 404);
}
return viewer;
}
async function findActiveAssignmentForViewer(userId) {
const now = new Date();
const assignment = await db.page_access_rules.findOne({
where: {
userId,
is_enabled: true,
[Op.and]: [
{
[Op.or]: [
{ effective_from: null },
{ effective_from: { [Op.lte]: now } },
],
},
{
[Op.or]: [
{ effective_until: null },
{ effective_until: { [Op.gte]: now } },
],
},
],
},
include: [
{
model: db.pdf_documents,
as: 'pdf_document',
required: true,
where: {
is_active: true,
},
include: [
{
model: db.file,
as: 'pdf_file',
required: false,
},
],
},
],
order: [
['updatedAt', 'desc'],
['createdAt', 'desc'],
],
});
if (!assignment) {
throw buildHttpError(
'Belum ada halaman PDF aktif yang ditugaskan untuk akun ini.',
404,
);
}
const rawAssignment = assignment.get({ plain: true });
if (!rawAssignment.pdf_document?.pdf_file?.length) {
throw buildHttpError('File PDF sumber belum tersedia untuk penugasan ini.', 404);
}
return assignment;
}
async function getViewerSessionFromToken(token) {
const { userId } = verifyViewerToken(token);
const viewer = await findManagedViewerById(userId);
if (viewer.disabled) {
throw buildHttpError('Akun viewer sedang dinonaktifkan.', 401);
}
const assignment = await findActiveAssignmentForViewer(viewer.id);
return {
viewer,
assignment,
};
}
function getDownloadBaseUrl(requestBaseUrl) {
if (process.env.NODE_ENV === 'dev_stage') {
return 'http://127.0.0.1:3000';
}
return requestBaseUrl;
}
async function listViewerAssignments(viewerId, transaction) {
return db.page_access_rules.findAll({
where: { userId: viewerId },
include: [
{
model: db.pdf_documents,
as: 'pdf_document',
required: false,
include: [
{
model: db.file,
as: 'pdf_file',
required: false,
},
],
},
],
order: [
['updatedAt', 'desc'],
['createdAt', 'desc'],
],
transaction,
});
}
module.exports = class PortalAccessService {
static async login(name, uniqueNumber) {
const normalizedName = normalizeName(name);
const normalizedUniqueNumber = normalizeValue(uniqueNumber);
if (!normalizedName || !normalizedUniqueNumber) {
throw buildHttpError('Nama dan nomor unik wajib diisi.');
}
const viewer = await db.users.findOne({
where: {
provider: VIEWER_PROVIDER,
disabled: false,
phoneNumber: normalizedUniqueNumber,
[Op.and]: [
Sequelize.where(
Sequelize.fn('LOWER', Sequelize.col('firstName')),
normalizedName.toLowerCase(),
),
],
},
});
if (!viewer) {
throw buildHttpError('Nama dan nomor unik tidak cocok.');
}
if (viewer.password) {
const isPasswordValid = await bcrypt.compare(
normalizedUniqueNumber,
viewer.password,
);
if (!isPasswordValid) {
throw buildHttpError('Nama dan nomor unik tidak cocok.');
}
}
const assignment = await findActiveAssignmentForViewer(viewer.id);
return {
token: buildViewerToken(viewer.id),
viewer: serializeViewer(viewer),
assignment: serializeAssignment(assignment),
};
}
static async getViewerSession(token) {
const { viewer, assignment } = await getViewerSessionFromToken(token);
return {
viewer: {
id: viewer.id,
name: viewer.firstName,
},
assignment: serializeAssignment(assignment),
};
}
static async getViewerDocumentBuffer(token, requestBaseUrl) {
const { assignment } = await getViewerSessionFromToken(token);
const rawAssignment = assignment.get({ plain: true });
const pdfSourceFile = rawAssignment.pdf_document?.pdf_file?.[0];
if (!pdfSourceFile) {
throw buildHttpError('File PDF tidak ditemukan.', 404);
}
const relativeDownloadPath = pdfSourceFile.publicUrl ||
`/api/file/download?privateUrl=${encodeURIComponent(pdfSourceFile.privateUrl)}`;
const downloadUrl = new URL(
relativeDownloadPath,
getDownloadBaseUrl(requestBaseUrl),
).toString();
let response;
try {
response = await axios.get(downloadUrl, {
responseType: 'arraybuffer',
});
} catch (error) {
console.error('Failed to fetch secured PDF source:', error?.response?.data || error.message || error);
throw buildHttpError('PDF tidak dapat dibuka saat ini.', 500);
}
return {
buffer: response.data,
filename: pdfSourceFile.name || rawAssignment.pdf_document?.file_name || 'document.pdf',
};
}
static async listViewerFeedback(token) {
const { viewer } = await getViewerSessionFromToken(token);
const feedbackRows = await db.user_feedback.findAll({
where: { userId: viewer.id },
include: [
{
model: db.page_access_rules,
as: 'page_access_rule',
required: false,
},
],
order: [
['submitted_at', 'desc'],
['createdAt', 'desc'],
],
limit: 10,
});
return feedbackRows.map((feedbackItem) => {
const rawFeedback = feedbackItem.get({ plain: true });
return {
id: rawFeedback.id,
message: rawFeedback.message,
status: rawFeedback.status,
submittedAt: rawFeedback.submitted_at || rawFeedback.createdAt,
reviewedAt: rawFeedback.reviewed_at || null,
adminNote: rawFeedback.is_visible_to_user ? rawFeedback.admin_note : null,
pageRuleName: rawFeedback.page_access_rule?.rule_name || null,
};
});
}
static async createViewerFeedback(token, message) {
const normalizedMessage = normalizeValue(message);
if (normalizedMessage.length < 3) {
throw buildHttpError('Masukan minimal terdiri dari 3 karakter.');
}
const { viewer, assignment } = await getViewerSessionFromToken(token);
const feedback = await db.user_feedback.create({
status: 'new',
message: normalizedMessage,
submitted_at: new Date(),
is_visible_to_user: true,
userId: viewer.id,
page_access_ruleId: assignment.id,
createdById: viewer.id,
updatedById: viewer.id,
});
return {
id: feedback.id,
message: feedback.message,
status: feedback.status,
submittedAt: feedback.submitted_at,
};
}
static async listManagedViewers() {
const viewers = await db.users.findAll({
where: {
provider: VIEWER_PROVIDER,
},
attributes: ['id', 'firstName', 'phoneNumber', 'disabled', 'createdAt', 'updatedAt'],
include: [
{
model: db.page_access_rules,
as: 'page_access_rules_user',
required: false,
include: [
{
model: db.pdf_documents,
as: 'pdf_document',
required: false,
include: [
{
model: db.file,
as: 'pdf_file',
required: false,
},
],
},
],
},
],
order: [['createdAt', 'desc']],
});
return viewers.map((viewer) => {
const viewerAssignments = Array.isArray(viewer.page_access_rules_user)
? viewer.page_access_rules_user
: [];
const sortedAssignments = [...viewerAssignments].sort((leftAssignment, rightAssignment) => {
const rightTimestamp = new Date(rightAssignment.updatedAt || rightAssignment.createdAt).getTime();
const leftTimestamp = new Date(leftAssignment.updatedAt || leftAssignment.createdAt).getTime();
return rightTimestamp - leftTimestamp;
});
const activeAssignment =
sortedAssignments.find((assignment) => assignment.is_enabled) ||
sortedAssignments[0] ||
null;
return serializeViewer(
viewer,
activeAssignment ? serializeAssignment(activeAssignment) : null,
);
});
}
static async createManagedViewer(data, currentUser) {
const name = normalizeName(data?.name);
const uniqueNumber = normalizeValue(data?.uniqueNumber);
if (!name || !uniqueNumber) {
throw buildHttpError('Nama dan nomor unik wajib diisi.');
}
const transaction = await db.sequelize.transaction();
try {
const existingViewer = await db.users.findOne({
where: {
provider: VIEWER_PROVIDER,
phoneNumber: uniqueNumber,
},
transaction,
});
if (existingViewer) {
throw buildHttpError('Nomor unik sudah dipakai oleh viewer lain.');
}
const passwordHash = await bcrypt.hash(uniqueNumber, config.bcrypt.saltRounds);
const viewerRole = await getViewerRole(transaction);
const viewer = await db.users.create(
{
firstName: name,
phoneNumber: uniqueNumber,
email: buildViewerEmail(uniqueNumber),
password: passwordHash,
disabled: false,
emailVerified: true,
provider: VIEWER_PROVIDER,
createdById: currentUser?.id || null,
updatedById: currentUser?.id || null,
},
{ transaction },
);
await viewer.setApp_role(viewerRole, { transaction });
await transaction.commit();
return serializeViewer(viewer);
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async updateManagedViewer(id, data, currentUser) {
const name = normalizeName(data?.name);
const uniqueNumber = normalizeValue(data?.uniqueNumber);
if (!name || !uniqueNumber) {
throw buildHttpError('Nama dan nomor unik wajib diisi.');
}
const transaction = await db.sequelize.transaction();
try {
const viewer = await findManagedViewerById(id, transaction);
const duplicateViewer = await db.users.findOne({
where: {
provider: VIEWER_PROVIDER,
phoneNumber: uniqueNumber,
id: {
[Op.ne]: id,
},
},
transaction,
});
if (duplicateViewer) {
throw buildHttpError('Nomor unik sudah dipakai oleh viewer lain.');
}
const passwordHash = await bcrypt.hash(uniqueNumber, config.bcrypt.saltRounds);
await viewer.update(
{
firstName: name,
phoneNumber: uniqueNumber,
email: buildViewerEmail(uniqueNumber),
password: passwordHash,
updatedById: currentUser?.id || null,
},
{ transaction },
);
await transaction.commit();
return serializeViewer(viewer);
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async removeManagedViewer(id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
const viewer = await findManagedViewerById(id, transaction);
await db.page_access_rules.update(
{
userId: null,
is_enabled: false,
effective_until: new Date(),
updatedById: currentUser?.id || null,
},
{
where: { userId: id },
transaction,
},
);
await viewer.destroy({ transaction });
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async upsertAssignment(data, currentUser) {
const viewerId = normalizeValue(data?.viewerId);
const pdfDocumentId = normalizeValue(data?.pdfDocumentId);
const pageNumber = Number(data?.pageNumber);
if (!viewerId || !pdfDocumentId || !Number.isInteger(pageNumber) || pageNumber < 1) {
throw buildHttpError('Viewer, dokumen PDF, dan nomor halaman wajib valid.');
}
const transaction = await db.sequelize.transaction();
try {
const viewer = await findManagedViewerById(viewerId, transaction);
const pdfDocument = await db.pdf_documents.findByPk(pdfDocumentId, {
transaction,
include: [
{
model: db.file,
as: 'pdf_file',
required: false,
},
],
});
if (!pdfDocument) {
throw buildHttpError('Dokumen PDF tidak ditemukan.', 404);
}
if (!pdfDocument.is_active) {
throw buildHttpError('Aktifkan dokumen PDF sebelum melakukan assignment.');
}
if (!pdfDocument.pdf_file?.length) {
throw buildHttpError('Dokumen PDF belum memiliki file sumber.');
}
if (pdfDocument.total_pages && pageNumber > pdfDocument.total_pages) {
throw buildHttpError(`Nomor halaman melebihi total halaman PDF (${pdfDocument.total_pages}).`);
}
let assignment = await db.page_access_rules.findOne({
where: { userId: viewerId },
order: [
['updatedAt', 'desc'],
['createdAt', 'desc'],
],
transaction,
});
const assignmentPayload = {
rule_name: `${viewer.firstName} · ${pdfDocument.title || 'PDF'} · halaman ${pageNumber}`,
page_number: pageNumber,
is_enabled: true,
effective_from: assignment?.effective_from || new Date(),
effective_until: null,
pdf_documentId: pdfDocumentId,
userId: viewerId,
updatedById: currentUser?.id || null,
};
if (assignment) {
await assignment.update(assignmentPayload, { transaction });
} else {
assignment = await db.page_access_rules.create(
{
...assignmentPayload,
createdById: currentUser?.id || null,
},
{ transaction },
);
}
await db.page_access_rules.update(
{
is_enabled: false,
effective_until: new Date(),
updatedById: currentUser?.id || null,
},
{
where: {
userId: viewerId,
id: {
[Op.ne]: assignment.id,
},
},
transaction,
},
);
await transaction.commit();
const [refreshedAssignment] = await listViewerAssignments(viewerId);
return serializeAssignment(refreshedAssignment || assignment);
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async clearAssignment(viewerId, currentUser) {
const normalizedViewerId = normalizeValue(viewerId);
if (!normalizedViewerId) {
throw buildHttpError('Viewer tidak ditemukan.', 404);
}
const transaction = await db.sequelize.transaction();
try {
const assignment = await db.page_access_rules.findOne({
where: { userId: normalizedViewerId },
order: [
['updatedAt', 'desc'],
['createdAt', 'desc'],
],
transaction,
});
if (!assignment) {
throw buildHttpError('Viewer ini belum memiliki assignment halaman.', 404);
}
await db.page_access_rules.update(
{
userId: null,
is_enabled: false,
effective_until: new Date(),
updatedById: currentUser?.id || null,
},
{
where: { userId: normalizedViewerId },
transaction,
},
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
};

View File

@ -1,6 +1,5 @@
import React, {useEffect, useRef} from 'react'
import React, {useEffect, useRef, useState} from 'react'
import Link from 'next/link'
import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon'

View File

@ -1,5 +1,4 @@
import React, { ReactNode, useEffect } from 'react'
import { useState } from 'react'
import React, { ReactNode, useEffect, useState } from 'react'
import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside'

View File

@ -7,6 +7,14 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiViewDashboardOutline,
label: 'Dashboard',
},
{
href: '/pdf-access-center',
label: 'Portal akses PDF',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiShieldLockOutline' in icon ? icon['mdiShieldLockOutline' as keyof typeof icon] : icon.mdiTable,
permissions: 'CREATE_USERS',
},
{
href: '/users/users-list',

View File

@ -1,166 +1,352 @@
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import {
mdiAccountKeyOutline,
mdiArrowRight,
mdiCheckCircleOutline,
mdiCommentTextOutline,
mdiFilePdfBox,
mdiLogin,
mdiShieldLockOutline,
} from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import Link from 'next/link';
import React, { useState } from 'react';
import type { ReactElement } from 'react';
import BaseButton from '../components/BaseButton';
import BaseIcon from '../components/BaseIcon';
import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import FormField from '../components/FormField';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
import LayoutGuest from '../layouts/Guest';
import { useRouter } from 'next/router';
const VIEWER_SESSION_STORAGE_KEY = 'viewerAccessToken';
export default function Starter() {
const [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
const [contentType, setContentType] = useState('image');
const [contentPosition, setContentPosition] = useState('right');
const textColor = useAppSelector((state) => state.style.linkColor);
const featureCards = [
{
icon: mdiShieldLockOutline,
title: 'Akses terkunci per user',
description: 'Setiap user login dengan nama dan nomor unik yang sudah ditentukan admin.',
},
{
icon: mdiFilePdfBox,
title: 'Hanya 1 halaman',
description: 'Viewer diarahkan langsung ke halaman PDF yang sudah di-assign dan tidak perlu mencari manual.',
},
{
icon: mdiCommentTextOutline,
title: 'Masukan cepat',
description: 'User dapat mengirim catatan perubahan data ke admin dari layar yang sama.',
},
];
const title = 'Secure PDF Page Access'
const adminWorkflow = [
{
icon: mdiFilePdfBox,
title: 'Upload PDF sumber',
description: 'Admin mengunggah file PDF dan menandai dokumen yang aktif untuk dibuka user.',
},
{
icon: mdiAccountKeyOutline,
title: 'Kelola nama + nomor unik',
description: 'Master data viewer dibuat sederhana: cukup nama penerima dan nomor unik sebagai password.',
},
{
icon: mdiCheckCircleOutline,
title: 'Assign halaman & review feedback',
description: 'Tentukan satu halaman per user, lalu pantau masukan perubahan data dari dashboard admin.',
},
];
// Fetch Pexels image/video
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage();
const video = await getPexelsVideo();
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
function getErrorMessage(error: unknown, fallback: string) {
if (axios.isAxiosError(error)) {
return String(error.response?.data || error.message || fallback);
}
const imageBlock = (image) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
if (error instanceof Error) {
return error.message;
}
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
return fallback;
}
export default function LandingPage() {
const router = useRouter();
const [form, setForm] = useState({
name: '',
uniqueNumber: '',
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [successMessage, setSuccessMessage] = useState('');
const handleViewerLogin = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage('');
setSuccessMessage('');
if (!form.name.trim() || !form.uniqueNumber.trim()) {
setErrorMessage('Nama dan nomor unik wajib diisi.');
return;
}
try {
setIsSubmitting(true);
const response = await axios.post('/portal-access/login', {
name: form.name,
uniqueNumber: form.uniqueNumber,
});
const token = response.data?.token;
const assignedPage = response.data?.assignment?.pageNumber;
if (!token) {
throw new Error('Sesi viewer tidak berhasil dibuat.');
}
sessionStorage.setItem(VIEWER_SESSION_STORAGE_KEY, token);
setSuccessMessage(
`Login berhasil. Membuka halaman ${assignedPage || ''} sekarang...`,
);
await router.push('/pdf-viewer');
} catch (error) {
setErrorMessage(
getErrorMessage(
error,
'Login gagal. Pastikan nama dan nomor unik sudah sesuai.',
),
);
} finally {
setIsSubmitting(false);
}
};
return (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<>
<Head>
<title>{getPageTitle('Starter Page')}</title>
<title>{getPageTitle('Portal Akses PDF')}</title>
</Head>
<SectionFullScreen bg='violet'>
<div
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
} min-h-screen w-full`}
>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your Secure PDF Page Access app!"/>
<div className="space-y-3">
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center text-gray-500'>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
<div className='min-h-screen bg-[#07111F] text-slate-100'>
<div className='relative overflow-hidden'>
<div className='absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(59,130,246,0.28),_transparent_38%),radial-gradient(circle_at_bottom_right,_rgba(14,165,233,0.18),_transparent_26%)]' />
<div className='relative mx-auto flex min-h-screen w-full max-w-7xl flex-col px-6 pb-12 pt-6 lg:px-10'>
<header className='mb-12 flex items-center justify-between gap-4 rounded-full border border-white/10 bg-white/5 px-5 py-3 backdrop-blur'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.32em] text-sky-300'>
Secure PDF Page Access
</p>
<p className='text-sm text-slate-300'>
Viewer aman untuk 1 halaman PDF per user.
</p>
</div>
<div className='flex items-center gap-3'>
<Link
href='/login'
className='rounded-full border border-white/15 px-4 py-2 text-sm font-medium text-white transition hover:border-sky-300/60 hover:bg-white/10'
>
Masuk Admin
</Link>
</div>
</header>
</BaseButtons>
</CardBox>
<main className='grid flex-1 items-center gap-10 lg:grid-cols-[1.05fr,0.95fr]'>
<section className='space-y-8'>
<div className='inline-flex items-center gap-2 rounded-full border border-sky-400/30 bg-sky-500/10 px-4 py-2 text-sm text-sky-200'>
<BaseIcon path={mdiShieldLockOutline} size={18} />
PDF dikunci sesuai assignment admin
</div>
<div className='space-y-5'>
<h1 className='max-w-3xl text-4xl font-black leading-tight text-white sm:text-5xl lg:text-6xl'>
Login, buka 1 halaman PDF yang ditugaskan, lalu kirim masukan perubahan data.
</h1>
<p className='max-w-2xl text-lg leading-8 text-slate-300'>
Halaman publik ini dibuat untuk alur yang sangat sederhana:
user memasukkan <span className='font-semibold text-white'>nama</span>{' '}
dan <span className='font-semibold text-white'>nomor unik</span>,
lalu sistem langsung menampilkan halaman PDF yang sudah ditentukan admin.
</p>
</div>
<div className='grid gap-4 sm:grid-cols-3'>
{featureCards.map((item) => (
<div
key={item.title}
className='rounded-3xl border border-white/10 bg-white/5 p-5 shadow-[0_24px_80px_rgba(2,8,23,0.22)] backdrop-blur'
>
<div className='mb-4 inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-sky-500/15 text-sky-300'>
<BaseIcon path={item.icon} size={24} />
</div>
<h2 className='mb-2 text-lg font-semibold text-white'>{item.title}</h2>
<p className='text-sm leading-6 text-slate-300'>{item.description}</p>
</div>
))}
</div>
<div className='flex flex-wrap items-center gap-4 text-sm text-slate-300'>
<div className='flex items-center gap-2'>
<BaseIcon path={mdiCheckCircleOutline} size={18} className='text-emerald-300' />
Satu user hanya melihat satu halaman yang di-assign.
</div>
<div className='flex items-center gap-2'>
<BaseIcon path={mdiCheckCircleOutline} size={18} className='text-emerald-300' />
Feedback user langsung tercatat untuk admin.
</div>
</div>
</section>
<section id='viewer-login'>
<CardBox className='border border-white/10 bg-white/95 text-slate-900 shadow-[0_30px_120px_rgba(15,23,42,0.35)]'>
<div className='mb-6 flex items-start justify-between gap-4'>
<div>
<p className='mb-2 inline-flex items-center gap-2 rounded-full bg-sky-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.24em] text-sky-700'>
<BaseIcon path={mdiLogin} size={16} />
Viewer Login
</p>
<h2 className='text-3xl font-bold text-slate-950'>Masuk untuk membuka halaman Anda</h2>
<p className='mt-2 text-sm leading-6 text-slate-600'>
Nomor unik berfungsi sebagai password. Setelah terverifikasi,
Anda langsung diarahkan ke halaman PDF yang sudah ditentukan.
</p>
</div>
</div>
<form onSubmit={handleViewerLogin} className='space-y-1'>
<FormField
label='Nama penerima'
help='Masukkan nama persis seperti data yang disiapkan admin.'
>
<input
value={form.name}
onChange={(event) =>
setForm((currentForm) => ({
...currentForm,
name: event.target.value,
}))
}
placeholder='Contoh: Budi Santoso'
autoComplete='name'
/>
</FormField>
<FormField
label='Nomor unik'
help='Nomor ini menjadi password untuk membuka halaman PDF Anda.'
>
<input
type='password'
value={form.uniqueNumber}
onChange={(event) =>
setForm((currentForm) => ({
...currentForm,
uniqueNumber: event.target.value,
}))
}
placeholder='Masukkan nomor unik'
autoComplete='current-password'
/>
</FormField>
{errorMessage ? (
<div className='rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700'>
{errorMessage}
</div>
) : null}
{successMessage ? (
<div className='rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700'>
{successMessage}
</div>
) : null}
<div className='pt-3'>
<BaseButton
type='submit'
color='info'
icon={mdiArrowRight}
label={isSubmitting ? 'Memverifikasi...' : 'Buka halaman saya'}
className='w-full justify-center py-3 text-base'
disabled={isSubmitting}
/>
</div>
</form>
<div className='mt-8 rounded-3xl border border-slate-200 bg-slate-50 p-5'>
<p className='text-sm font-semibold text-slate-900'>Untuk admin</p>
<p className='mt-2 text-sm leading-6 text-slate-600'>
Upload PDF sumber, kelola nama + nomor unik, lalu tentukan halaman
yang boleh dibuka user dari dashboard admin.
</p>
<div className='mt-4 flex flex-wrap gap-3'>
<BaseButton href='/login' color='info' label='Masuk ke dashboard admin' />
</div>
</div>
</CardBox>
</section>
</main>
</div>
</div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
</div>
<section className='bg-white text-slate-900'>
<div className='mx-auto w-full max-w-7xl px-6 py-20 lg:px-10'>
<div className='mb-10 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between'>
<div className='max-w-3xl'>
<p className='mb-3 text-sm font-semibold uppercase tracking-[0.28em] text-sky-700'>
Alur awal yang paling penting
</p>
<h2 className='text-3xl font-bold text-slate-950 sm:text-4xl'>
Dari upload PDF sampai feedback user, semuanya mengalir dalam satu workflow sederhana.
</h2>
</div>
<Link
href='/login'
className='inline-flex items-center gap-2 text-sm font-semibold text-sky-700 transition hover:text-sky-900'
>
Buka admin interface
<BaseIcon path={mdiArrowRight} size={18} />
</Link>
</div>
<div className='grid gap-6 lg:grid-cols-3'>
{adminWorkflow.map((item, index) => (
<div
key={item.title}
className='rounded-[28px] border border-slate-200 bg-slate-50 p-6 shadow-sm'
>
<div className='mb-5 flex items-center justify-between'>
<div className='inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-sky-100 text-sky-700'>
<BaseIcon path={item.icon} size={24} />
</div>
<span className='text-sm font-semibold text-slate-400'>0{index + 1}</span>
</div>
<h3 className='mb-3 text-xl font-semibold text-slate-950'>{item.title}</h3>
<p className='text-sm leading-7 text-slate-600'>{item.description}</p>
</div>
))}
</div>
</div>
</section>
<footer className='border-t border-white/10 bg-[#050C18]'>
<div className='mx-auto flex w-full max-w-7xl flex-col gap-4 px-6 py-6 text-sm text-slate-400 md:flex-row md:items-center md:justify-between lg:px-10'>
<p>© 2026 Secure PDF Page Access. Halaman publik untuk viewer dan admin.</p>
<div className='flex flex-wrap items-center gap-4'>
<Link href='/login' className='transition hover:text-white'>
Login Admin
</Link>
<Link href='/privacy-policy' className='transition hover:text-white'>
Privacy Policy
</Link>
</div>
</div>
</footer>
</div>
</>
);
}
Starter.getLayout = function getLayout(page: ReactElement) {
LandingPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -0,0 +1,884 @@
import {
mdiCommentTextOutline,
mdiDeleteOutline,
mdiFilePdfBox,
mdiOpenInNew,
mdiPencilOutline,
mdiPlus,
mdiRefresh,
mdiShieldLockOutline,
} from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import Link from 'next/link';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { ReactElement } from 'react';
import BaseButton from '../components/BaseButton';
import BaseDivider from '../components/BaseDivider';
import BaseIcon from '../components/BaseIcon';
import CardBox from '../components/CardBox';
import FormField from '../components/FormField';
import LoadingSpinner from '../components/LoadingSpinner';
import SectionMain from '../components/SectionMain';
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
import { getPageTitle } from '../config';
import { hasPermission } from '../helpers/userPermissions';
import LayoutAuthenticated from '../layouts/Authenticated';
import { useAppSelector } from '../stores/hooks';
type ViewerRecord = {
id: string;
name: string;
uniqueNumber: string;
disabled?: boolean;
assignment?: {
id: string;
pageNumber: number;
isEnabled?: boolean;
pdfDocument?: {
id: string;
title: string;
totalPages?: number | null;
isActive?: boolean;
} | null;
} | null;
};
type PdfDocumentRecord = {
id: string;
title: string;
total_pages?: number | null;
is_active?: boolean;
published_at?: string | null;
};
type FeedbackPreview = {
id: string;
status: string;
message: string;
submitted_at?: string | null;
user?: {
firstName?: string | null;
} | null;
page_access_rule?: {
rule_name?: string | null;
} | null;
};
type ViewerFormState = {
id: string;
name: string;
uniqueNumber: string;
};
type AssignmentFormState = {
viewerId: string;
pdfDocumentId: string;
pageNumber: string;
};
const initialViewerForm: ViewerFormState = {
id: '',
name: '',
uniqueNumber: '',
};
const initialAssignmentForm: AssignmentFormState = {
viewerId: '',
pdfDocumentId: '',
pageNumber: '',
};
function getErrorMessage(error: unknown, fallback: string) {
if (axios.isAxiosError(error)) {
return String(error.response?.data || error.message || fallback);
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
function formatDate(value?: string | null) {
if (!value) {
return '-';
}
return new Intl.DateTimeFormat('id-ID', {
dateStyle: 'medium',
}).format(new Date(value));
}
function getFeedbackStatusClasses(status: string) {
switch (status) {
case 'resolved':
return 'bg-emerald-100 text-emerald-700 border-emerald-200';
case 'reviewed':
return 'bg-sky-100 text-sky-700 border-sky-200';
case 'rejected':
return 'bg-red-100 text-red-700 border-red-200';
default:
return 'bg-amber-100 text-amber-700 border-amber-200';
}
}
export default function PdfAccessCenterPage() {
const { currentUser } = useAppSelector((state) => state.auth);
const [viewers, setViewers] = useState<ViewerRecord[]>([]);
const [pdfDocuments, setPdfDocuments] = useState<PdfDocumentRecord[]>([]);
const [feedbackPreview, setFeedbackPreview] = useState<FeedbackPreview[]>([]);
const [viewerForm, setViewerForm] = useState<ViewerFormState>(initialViewerForm);
const [assignmentForm, setAssignmentForm] = useState<AssignmentFormState>(initialAssignmentForm);
const [loading, setLoading] = useState(true);
const [reloading, setReloading] = useState(false);
const [savingViewer, setSavingViewer] = useState(false);
const [savingAssignment, setSavingAssignment] = useState(false);
const [notice, setNotice] = useState({
type: '',
text: '',
});
const canCreateViewer = hasPermission(currentUser, 'CREATE_USERS');
const canUpdateViewer = hasPermission(currentUser, 'UPDATE_USERS');
const canDeleteViewer = hasPermission(currentUser, 'DELETE_USERS');
const canAssignPage = hasPermission(currentUser, ['CREATE_PAGE_ACCESS_RULES', 'UPDATE_PAGE_ACCESS_RULES']);
const selectedPdfDocument = useMemo(
() => pdfDocuments.find((item) => item.id === assignmentForm.pdfDocumentId) || null,
[assignmentForm.pdfDocumentId, pdfDocuments],
);
const latestActivePdfCount = useMemo(
() => pdfDocuments.filter((item) => item.is_active).length,
[pdfDocuments],
);
const viewersWithAssignmentCount = useMemo(
() => viewers.filter((item) => item.assignment?.pdfDocument).length,
[viewers],
);
const loadPortalData = useCallback(async (isRefresh = false) => {
if (isRefresh) {
setReloading(true);
} else {
setLoading(true);
}
try {
const [viewersResponse, pdfDocumentsResponse, feedbackResponse] = await Promise.all([
axios.get('/portal-access/viewers'),
axios.get('/pdf_documents?limit=100&page=0'),
axios.get('/user_feedback?limit=5&page=0'),
]);
setViewers(viewersResponse.data || []);
setPdfDocuments(pdfDocumentsResponse.data?.rows || []);
setFeedbackPreview(feedbackResponse.data?.rows || []);
} catch (error) {
setNotice({
type: 'error',
text: getErrorMessage(
error,
'Dashboard akses PDF belum bisa dimuat. Silakan coba lagi.',
),
});
} finally {
setLoading(false);
setReloading(false);
}
}, []);
useEffect(() => {
loadPortalData();
}, [loadPortalData]);
const resetViewerForm = () => {
setViewerForm(initialViewerForm);
};
const resetAssignmentForm = () => {
setAssignmentForm(initialAssignmentForm);
};
const handleViewerSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setNotice({ type: '', text: '' });
if (!viewerForm.name.trim() || !viewerForm.uniqueNumber.trim()) {
setNotice({
type: 'error',
text: 'Nama dan nomor unik wajib diisi.',
});
return;
}
try {
setSavingViewer(true);
if (viewerForm.id) {
await axios.put(`/portal-access/viewers/${viewerForm.id}`, {
name: viewerForm.name,
uniqueNumber: viewerForm.uniqueNumber,
});
setNotice({
type: 'success',
text: 'Data viewer berhasil diperbarui.',
});
} else {
await axios.post('/portal-access/viewers', {
name: viewerForm.name,
uniqueNumber: viewerForm.uniqueNumber,
});
setNotice({
type: 'success',
text: 'Viewer baru berhasil ditambahkan.',
});
}
resetViewerForm();
await loadPortalData(true);
} catch (error) {
setNotice({
type: 'error',
text: getErrorMessage(
error,
'Data viewer belum berhasil disimpan.',
),
});
} finally {
setSavingViewer(false);
}
};
const handleAssignmentSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setNotice({ type: '', text: '' });
if (!assignmentForm.viewerId || !assignmentForm.pdfDocumentId || !assignmentForm.pageNumber) {
setNotice({
type: 'error',
text: 'Pilih viewer, pilih PDF, dan isi nomor halaman.',
});
return;
}
try {
setSavingAssignment(true);
await axios.post('/portal-access/assignments', {
viewerId: assignmentForm.viewerId,
pdfDocumentId: assignmentForm.pdfDocumentId,
pageNumber: Number(assignmentForm.pageNumber),
});
setNotice({
type: 'success',
text: 'Assignment halaman berhasil disimpan.',
});
resetAssignmentForm();
await loadPortalData(true);
} catch (error) {
setNotice({
type: 'error',
text: getErrorMessage(
error,
'Assignment halaman belum berhasil disimpan.',
),
});
} finally {
setSavingAssignment(false);
}
};
const handleEditViewer = (viewer: ViewerRecord) => {
setViewerForm({
id: viewer.id,
name: viewer.name,
uniqueNumber: viewer.uniqueNumber,
});
setNotice({ type: '', text: '' });
};
const handlePrepareAssignment = (viewer: ViewerRecord) => {
setAssignmentForm({
viewerId: viewer.id,
pdfDocumentId: viewer.assignment?.pdfDocument?.id || '',
pageNumber: viewer.assignment?.pageNumber ? String(viewer.assignment.pageNumber) : '',
});
setNotice({ type: '', text: '' });
};
const handleDeleteViewer = async (viewer: ViewerRecord) => {
if (!window.confirm(`Hapus viewer ${viewer.name}?`)) {
return;
}
try {
setNotice({ type: '', text: '' });
await axios.delete(`/portal-access/viewers/${viewer.id}`);
setNotice({
type: 'success',
text: 'Viewer berhasil dihapus.',
});
if (viewerForm.id === viewer.id) {
resetViewerForm();
}
if (assignmentForm.viewerId === viewer.id) {
resetAssignmentForm();
}
await loadPortalData(true);
} catch (error) {
setNotice({
type: 'error',
text: getErrorMessage(error, 'Viewer belum berhasil dihapus.'),
});
}
};
const handleClearAssignment = async (viewer: ViewerRecord) => {
if (!window.confirm(`Lepaskan assignment halaman untuk ${viewer.name}?`)) {
return;
}
try {
await axios.delete(`/portal-access/assignments/${viewer.id}`);
setNotice({
type: 'success',
text: 'Assignment halaman berhasil dilepas.',
});
if (assignmentForm.viewerId === viewer.id) {
resetAssignmentForm();
}
await loadPortalData(true);
} catch (error) {
setNotice({
type: 'error',
text: getErrorMessage(error, 'Assignment belum berhasil dilepas.'),
});
}
};
const noticeClassName =
notice.type === 'success'
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
: 'border-red-200 bg-red-50 text-red-700';
return (
<>
<Head>
<title>{getPageTitle('Portal Akses PDF')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiShieldLockOutline}
title='Portal akses PDF'
main
>
<BaseButton
href='/'
target='_blank'
label='Buka login user'
icon={mdiOpenInNew}
color='info'
/>
</SectionTitleLineWithButton>
{loading ? <LoadingSpinner /> : null}
{!loading ? (
<div className='space-y-6'>
<div className='grid gap-6 xl:grid-cols-[1.15fr,0.85fr]'>
<CardBox className='border-transparent bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 text-white shadow-[0_26px_90px_rgba(15,23,42,0.18)]'>
<div className='space-y-6'>
<div className='flex flex-wrap items-start justify-between gap-4'>
<div className='max-w-2xl'>
<p className='mb-3 text-xs font-semibold uppercase tracking-[0.26em] text-sky-200'>
Initial MVP slice
</p>
<h1 className='text-3xl font-bold leading-tight text-white'>
Kelola viewer, assign 1 halaman PDF, dan pantau feedback dari satu tempat.
</h1>
<p className='mt-3 text-sm leading-7 text-slate-300'>
Halaman ini melengkapi CRUD bawaan dengan workflow domain yang lebih natural:
admin cukup upload PDF sumber, mengisi master data viewer, lalu menentukan
halaman yang boleh dibuka oleh masing-masing user.
</p>
</div>
<BaseButton
label={reloading ? 'Menyegarkan...' : 'Refresh data'}
icon={mdiRefresh}
color='light'
outline
onClick={() => loadPortalData(true)}
disabled={reloading}
/>
</div>
<div className='grid gap-4 md:grid-cols-3'>
<div className='rounded-3xl border border-white/10 bg-white/10 p-5'>
<p className='text-sm text-sky-200'>Viewer aktif</p>
<p className='mt-2 text-4xl font-black text-white'>{viewers.length}</p>
</div>
<div className='rounded-3xl border border-white/10 bg-white/10 p-5'>
<p className='text-sm text-sky-200'>PDF aktif</p>
<p className='mt-2 text-4xl font-black text-white'>{latestActivePdfCount}</p>
</div>
<div className='rounded-3xl border border-white/10 bg-white/10 p-5'>
<p className='text-sm text-sky-200'>Viewer ter-assign</p>
<p className='mt-2 text-4xl font-black text-white'>{viewersWithAssignmentCount}</p>
</div>
</div>
</div>
</CardBox>
<CardBox>
<div className='space-y-5'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.24em] text-sky-700'>Quick links</p>
<h2 className='mt-2 text-2xl font-bold text-slate-950'>Langkah admin yang paling sering dipakai</h2>
</div>
<div className='space-y-4'>
<Link
href='/pdf_documents/pdf_documents-list'
className='flex items-start gap-4 rounded-3xl border border-slate-200 bg-slate-50 p-4 transition hover:border-sky-300 hover:bg-sky-50'
>
<span className='inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-sky-100 text-sky-700'>
<BaseIcon path={mdiFilePdfBox} size={24} />
</span>
<span>
<span className='block font-semibold text-slate-950'>Upload & kelola PDF sumber</span>
<span className='mt-1 block text-sm leading-6 text-slate-600'>
Aktifkan dokumen yang akan dijadikan sumber halaman viewer.
</span>
</span>
</Link>
<Link
href='/user_feedback/user_feedback-list'
className='flex items-start gap-4 rounded-3xl border border-slate-200 bg-slate-50 p-4 transition hover:border-sky-300 hover:bg-sky-50'
>
<span className='inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-sky-100 text-sky-700'>
<BaseIcon path={mdiCommentTextOutline} size={24} />
</span>
<span>
<span className='block font-semibold text-slate-950'>Buka inbox feedback user</span>
<span className='mt-1 block text-sm leading-6 text-slate-600'>
Review perubahan data yang dikirim dari halaman viewer.
</span>
</span>
</Link>
<Link
href='/'
target='_blank'
className='flex items-start gap-4 rounded-3xl border border-slate-200 bg-slate-50 p-4 transition hover:border-sky-300 hover:bg-sky-50'
>
<span className='inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-sky-100 text-sky-700'>
<BaseIcon path={mdiOpenInNew} size={24} />
</span>
<span>
<span className='block font-semibold text-slate-950'>Cek halaman publik viewer</span>
<span className='mt-1 block text-sm leading-6 text-slate-600'>
Gunakan link ini untuk mencoba login viewer dari sisi user.
</span>
</span>
</Link>
</div>
</div>
</CardBox>
</div>
{notice.text ? (
<div className={`rounded-3xl border px-5 py-4 text-sm ${noticeClassName}`}>
{notice.text}
</div>
) : null}
<div className='grid gap-6 xl:grid-cols-2'>
<CardBox>
<div className='space-y-5'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.24em] text-sky-700'>Master data viewer</p>
<h2 className='mt-2 text-2xl font-bold text-slate-950'>Nama + nomor unik</h2>
<p className='mt-2 text-sm leading-6 text-slate-600'>
Nomor unik disimpan sebagai password viewer. Begitu user login,
sistem langsung mengarahkan ke halaman yang sudah di-assign.
</p>
</div>
<form onSubmit={handleViewerSubmit} className='space-y-1'>
<FormField
label='Nama viewer'
help='Isi nama sesuai data master yang nanti akan dipakai saat login.'
>
<input
value={viewerForm.name}
onChange={(event) =>
setViewerForm((currentForm) => ({
...currentForm,
name: event.target.value,
}))
}
placeholder='Contoh: Budi Santoso'
disabled={viewerForm.id ? !canUpdateViewer : !canCreateViewer}
/>
</FormField>
<FormField
label='Nomor unik'
help='Nomor unik ini juga menjadi password viewer.'
>
<input
value={viewerForm.uniqueNumber}
onChange={(event) =>
setViewerForm((currentForm) => ({
...currentForm,
uniqueNumber: event.target.value,
}))
}
placeholder='Contoh: INV-2026-001'
disabled={viewerForm.id ? !canUpdateViewer : !canCreateViewer}
/>
</FormField>
<div className='flex flex-wrap gap-3 pt-3'>
<BaseButton
type='submit'
color='info'
icon={viewerForm.id ? mdiPencilOutline : mdiPlus}
label={
savingViewer
? 'Menyimpan...'
: viewerForm.id
? 'Perbarui viewer'
: 'Tambah viewer'
}
disabled={
savingViewer ||
(viewerForm.id ? !canUpdateViewer : !canCreateViewer)
}
/>
<BaseButton
type='button'
color='light'
outline
label='Reset form'
onClick={resetViewerForm}
/>
</div>
</form>
</div>
</CardBox>
<CardBox>
<div className='space-y-5'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.24em] text-sky-700'>Assignment halaman</p>
<h2 className='mt-2 text-2xl font-bold text-slate-950'>Atur 1 halaman per viewer</h2>
<p className='mt-2 text-sm leading-6 text-slate-600'>
Pilih viewer, pilih PDF yang aktif, lalu isi nomor halaman yang akan tampil
di viewer publik. Satu viewer hanya membutuhkan satu assignment aktif.
</p>
</div>
<form onSubmit={handleAssignmentSubmit} className='space-y-1'>
<FormField
label='Viewer'
help='Pilih viewer yang akan dibukakan halaman PDF.'
>
<select
value={assignmentForm.viewerId}
onChange={(event) =>
setAssignmentForm((currentForm) => ({
...currentForm,
viewerId: event.target.value,
}))
}
disabled={!canAssignPage}
>
<option value=''>Pilih viewer</option>
{viewers.map((viewer) => (
<option key={viewer.id} value={viewer.id}>
{viewer.name}
</option>
))}
</select>
</FormField>
<FormField
label='Dokumen PDF'
help='Hanya dokumen aktif yang ideal untuk dijadikan sumber viewer.'
>
<select
value={assignmentForm.pdfDocumentId}
onChange={(event) =>
setAssignmentForm((currentForm) => ({
...currentForm,
pdfDocumentId: event.target.value,
}))
}
disabled={!canAssignPage}
>
<option value=''>Pilih PDF</option>
{pdfDocuments.map((pdfDocument) => (
<option key={pdfDocument.id} value={pdfDocument.id}>
{pdfDocument.title}
{pdfDocument.is_active ? ' · aktif' : ' · nonaktif'}
</option>
))}
</select>
</FormField>
<FormField
label='Nomor halaman'
help={
selectedPdfDocument?.total_pages
? `PDF ini memiliki ${selectedPdfDocument.total_pages} halaman.`
: 'Isi nomor halaman yang ingin dibuka viewer.'
}
>
<input
type='number'
min='1'
value={assignmentForm.pageNumber}
onChange={(event) =>
setAssignmentForm((currentForm) => ({
...currentForm,
pageNumber: event.target.value,
}))
}
placeholder='Contoh: 12'
disabled={!canAssignPage}
/>
</FormField>
<div className='flex flex-wrap gap-3 pt-3'>
<BaseButton
type='submit'
color='info'
icon={mdiShieldLockOutline}
label={savingAssignment ? 'Menyimpan...' : 'Simpan assignment'}
disabled={savingAssignment || !canAssignPage}
/>
<BaseButton
type='button'
color='light'
outline
label='Reset form'
onClick={resetAssignmentForm}
/>
</div>
</form>
</div>
</CardBox>
</div>
<div className='grid gap-6 xl:grid-cols-[1.25fr,0.75fr]'>
<CardBox>
<div className='space-y-5'>
<div className='flex flex-wrap items-center justify-between gap-3'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.24em] text-sky-700'>Daftar viewer</p>
<h2 className='mt-2 text-2xl font-bold text-slate-950'>Status master data & assignment</h2>
</div>
<Link
href='/users/users-list'
className='text-sm font-semibold text-sky-700 transition hover:text-sky-900'
>
Lihat semua user bawaan
</Link>
</div>
<div className='overflow-hidden rounded-[28px] border border-slate-200'>
<div className='overflow-x-auto'>
<table className='min-w-full divide-y divide-slate-200 text-sm'>
<thead className='bg-slate-50 text-left text-slate-500'>
<tr>
<th className='px-5 py-4 font-semibold'>Viewer</th>
<th className='px-5 py-4 font-semibold'>Nomor unik</th>
<th className='px-5 py-4 font-semibold'>Assignment</th>
<th className='px-5 py-4 font-semibold'>Aksi</th>
</tr>
</thead>
<tbody className='divide-y divide-slate-200 bg-white'>
{viewers.length === 0 ? (
<tr>
<td colSpan={4} className='px-5 py-10 text-center text-slate-500'>
Belum ada viewer. Tambahkan master data viewer pertama Anda.
</td>
</tr>
) : (
viewers.map((viewer) => (
<tr key={viewer.id} className='align-top'>
<td className='px-5 py-4'>
<p className='font-semibold text-slate-950'>{viewer.name}</p>
<p className='mt-1 text-xs text-slate-500'>ID: {viewer.id.slice(0, 8)}...</p>
</td>
<td className='px-5 py-4 text-slate-700'>{viewer.uniqueNumber}</td>
<td className='px-5 py-4'>
{viewer.assignment?.pdfDocument ? (
<div className='space-y-2'>
<div className='inline-flex rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-emerald-700'>
aktif
</div>
<div className='text-slate-900'>
<p className='font-semibold'>{viewer.assignment.pdfDocument.title}</p>
<p className='text-xs text-slate-500'>
Halaman {viewer.assignment.pageNumber}
{viewer.assignment.pdfDocument.totalPages
? ` dari ${viewer.assignment.pdfDocument.totalPages}`
: ''}
</p>
</div>
</div>
) : (
<div className='inline-flex rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-slate-500'>
belum ada assignment
</div>
)}
</td>
<td className='px-5 py-4'>
<div className='flex flex-wrap gap-2'>
<BaseButton
small
color='info'
outline
icon={mdiPencilOutline}
label='Edit'
onClick={() => handleEditViewer(viewer)}
disabled={!canUpdateViewer}
/>
<BaseButton
small
color='success'
outline
icon={mdiShieldLockOutline}
label='Atur halaman'
onClick={() => handlePrepareAssignment(viewer)}
disabled={!canAssignPage}
/>
<BaseButton
small
color='warning'
outline
icon={mdiRefresh}
label='Lepas akses'
onClick={() => handleClearAssignment(viewer)}
disabled={!canAssignPage || !viewer.assignment?.pdfDocument}
/>
<BaseButton
small
color='danger'
outline
icon={mdiDeleteOutline}
label='Hapus'
onClick={() => handleDeleteViewer(viewer)}
disabled={!canDeleteViewer}
/>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</CardBox>
<div className='space-y-6'>
<CardBox>
<div className='space-y-4'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.24em] text-sky-700'>PDF terbaru</p>
<h2 className='mt-2 text-2xl font-bold text-slate-950'>Dokumen sumber</h2>
</div>
{pdfDocuments.length === 0 ? (
<div className='rounded-3xl border border-dashed border-slate-200 bg-slate-50 p-5 text-sm leading-6 text-slate-500'>
Belum ada PDF yang diunggah. Upload PDF terlebih dahulu sebelum membuat assignment halaman.
</div>
) : (
<div className='space-y-4'>
{pdfDocuments.slice(0, 4).map((pdfDocument) => (
<div key={pdfDocument.id} className='rounded-3xl border border-slate-200 bg-slate-50 p-4'>
<div className='flex items-center justify-between gap-3'>
<div>
<p className='font-semibold text-slate-950'>{pdfDocument.title}</p>
<p className='mt-1 text-xs text-slate-500'>
{pdfDocument.total_pages
? `${pdfDocument.total_pages} halaman`
: 'Total halaman belum diisi'}
</p>
</div>
<span className={`inline-flex rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] ${pdfDocument.is_active ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-slate-200 bg-white text-slate-500'}`}>
{pdfDocument.is_active ? 'aktif' : 'draft'}
</span>
</div>
</div>
))}
</div>
)}
</div>
</CardBox>
<CardBox>
<div className='space-y-4'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.24em] text-sky-700'>Feedback terbaru</p>
<h2 className='mt-2 text-2xl font-bold text-slate-950'>Masukan user masuk ke sini</h2>
</div>
{feedbackPreview.length === 0 ? (
<div className='rounded-3xl border border-dashed border-slate-200 bg-slate-50 p-5 text-sm leading-6 text-slate-500'>
Belum ada feedback dari viewer. Setelah user login dan mengirim masukan, ringkasannya tampil di sini.
</div>
) : (
<div className='space-y-4'>
{feedbackPreview.map((item) => (
<div key={item.id} className='rounded-3xl border border-slate-200 bg-slate-50 p-4'>
<div className='mb-3 flex flex-wrap items-center justify-between gap-3'>
<span className={`inline-flex rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] ${getFeedbackStatusClasses(item.status)}`}>
{item.status}
</span>
<span className='text-xs text-slate-500'>{formatDate(item.submitted_at)}</span>
</div>
<p className='text-sm leading-7 text-slate-700'>{item.message}</p>
<BaseDivider />
<p className='text-xs text-slate-500'>
{item.user?.firstName || 'Viewer'}
{item.page_access_rule?.rule_name
? ` · ${item.page_access_rule.rule_name}`
: ''}
</p>
</div>
))}
</div>
)}
</div>
</CardBox>
</div>
</div>
</div>
) : null}
</SectionMain>
</>
);
}
PdfAccessCenterPage.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated permission='CREATE_USERS'>
{page}
</LayoutAuthenticated>
);
};

View File

@ -0,0 +1,573 @@
import {
mdiArrowLeft,
mdiCheckCircleOutline,
mdiCommentTextOutline,
mdiFilePdfBox,
mdiShieldLockOutline,
} from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ReactElement } from 'react';
import BaseButton from '../components/BaseButton';
import BaseIcon from '../components/BaseIcon';
import CardBox from '../components/CardBox';
import FormField from '../components/FormField';
import LoadingSpinner from '../components/LoadingSpinner';
import { getPageTitle } from '../config';
import LayoutGuest from '../layouts/Guest';
import { useRouter } from 'next/router';
const VIEWER_SESSION_STORAGE_KEY = 'viewerAccessToken';
const PDFJS_SCRIPT_URL = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.min.js';
const PDFJS_WORKER_URL = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.worker.min.js';
declare global {
interface Window {
pdfjsLib?: any;
}
}
type ViewerSession = {
viewer: {
id: string;
name: string;
};
assignment: {
id: string;
ruleName: string;
pageNumber: number;
effectiveFrom?: string | null;
pdfDocument?: {
id: string;
title: string;
totalPages?: number | null;
sourceFileName?: string | null;
} | null;
};
};
type ViewerFeedbackItem = {
id: string;
message: string;
status: string;
submittedAt?: string | null;
reviewedAt?: string | null;
adminNote?: string | null;
pageRuleName?: string | null;
};
function getErrorMessage(error: unknown, fallback: string) {
if (axios.isAxiosError(error)) {
return String(error.response?.data || error.message || fallback);
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
function formatDate(value?: string | null) {
if (!value) {
return '-';
}
return new Intl.DateTimeFormat('id-ID', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
}
function getStatusStyles(status: string) {
switch (status) {
case 'resolved':
return 'bg-emerald-100 text-emerald-700 border-emerald-200';
case 'reviewed':
return 'bg-sky-100 text-sky-700 border-sky-200';
case 'rejected':
return 'bg-red-100 text-red-700 border-red-200';
default:
return 'bg-amber-100 text-amber-700 border-amber-200';
}
}
function loadPdfJsLibrary() {
return new Promise<any>((resolve, reject) => {
if (typeof window === 'undefined') {
resolve(null);
return;
}
if (window.pdfjsLib) {
resolve(window.pdfjsLib);
return;
}
const existingScript = document.getElementById('pdfjs-cdn-script') as HTMLScriptElement | null;
if (existingScript) {
existingScript.addEventListener('load', () => resolve(window.pdfjsLib));
existingScript.addEventListener('error', () => reject(new Error('PDF.js gagal dimuat.')));
return;
}
const script = document.createElement('script');
script.id = 'pdfjs-cdn-script';
script.src = PDFJS_SCRIPT_URL;
script.async = true;
script.onload = () => resolve(window.pdfjsLib);
script.onerror = () => reject(new Error('PDF.js gagal dimuat.'));
document.body.appendChild(script);
});
}
export default function PdfViewerPage() {
const router = useRouter();
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [viewerToken, setViewerToken] = useState('');
const [session, setSession] = useState<ViewerSession | null>(null);
const [feedbackItems, setFeedbackItems] = useState<ViewerFeedbackItem[]>([]);
const [feedbackMessage, setFeedbackMessage] = useState('');
const [isPageLoading, setIsPageLoading] = useState(true);
const [isPdfLoading, setIsPdfLoading] = useState(false);
const [isSubmittingFeedback, setIsSubmittingFeedback] = useState(false);
const [pageError, setPageError] = useState('');
const [pdfError, setPdfError] = useState('');
const [feedbackError, setFeedbackError] = useState('');
const [feedbackSuccess, setFeedbackSuccess] = useState('');
const assignmentTitle = useMemo(() => {
if (!session?.assignment?.pdfDocument?.title) {
return 'Halaman PDF';
}
return session.assignment.pdfDocument.title;
}, [session]);
const loadFeedbackHistory = useCallback(async (token: string) => {
const response = await axios.get('/portal-access/feedback', {
headers: {
'x-viewer-session': token,
},
});
setFeedbackItems(response.data || []);
}, []);
const renderAssignedPage = useCallback(async (token: string, pageNumber: number) => {
if (!pageNumber) {
return;
}
setPdfError('');
setIsPdfLoading(true);
try {
const pdfjsLib = await loadPdfJsLibrary();
if (!pdfjsLib) {
throw new Error('Viewer PDF tidak tersedia di browser ini.');
}
pdfjsLib.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_URL;
const response = await axios.get('/portal-access/document', {
headers: {
'x-viewer-session': token,
},
responseType: 'arraybuffer',
});
const pdfDocument = await pdfjsLib.getDocument({ data: response.data }).promise;
const pdfPage = await pdfDocument.getPage(pageNumber);
const canvas = canvasRef.current;
if (!canvas) {
return;
}
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Canvas viewer tidak tersedia.');
}
const parentWidth = canvas.parentElement?.clientWidth || 900;
const initialViewport = pdfPage.getViewport({ scale: 1 });
const scale = Math.min(Math.max(parentWidth / initialViewport.width, 1), 2.2);
const viewport = pdfPage.getViewport({ scale });
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.style.width = '100%';
canvas.style.height = 'auto';
await pdfPage.render({
canvasContext: context,
viewport,
}).promise;
} catch (error) {
console.error('PDF render failed:', error);
setPdfError(
getErrorMessage(
error,
'Halaman PDF belum bisa dirender. Silakan coba lagi atau hubungi admin.',
),
);
} finally {
setIsPdfLoading(false);
}
}, []);
const loadViewerSession = useCallback(async (token: string) => {
setIsPageLoading(true);
setPageError('');
try {
const response = await axios.get('/portal-access/session', {
headers: {
'x-viewer-session': token,
},
});
setSession(response.data);
await loadFeedbackHistory(token);
await renderAssignedPage(token, response.data?.assignment?.pageNumber);
} catch (error) {
const message = getErrorMessage(
error,
'Sesi viewer tidak ditemukan. Silakan login ulang.',
);
setPageError(message);
sessionStorage.removeItem(VIEWER_SESSION_STORAGE_KEY);
} finally {
setIsPageLoading(false);
}
}, [loadFeedbackHistory, renderAssignedPage]);
useEffect(() => {
if (!router.isReady) {
return;
}
const savedViewerToken = sessionStorage.getItem(VIEWER_SESSION_STORAGE_KEY);
if (!savedViewerToken) {
router.replace('/');
return;
}
setViewerToken(savedViewerToken);
loadViewerSession(savedViewerToken);
}, [loadViewerSession, router]);
const handleExitViewer = async () => {
sessionStorage.removeItem(VIEWER_SESSION_STORAGE_KEY);
await router.push('/');
};
const handleSubmitFeedback = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setFeedbackError('');
setFeedbackSuccess('');
if (!feedbackMessage.trim()) {
setFeedbackError('Masukan tidak boleh kosong.');
return;
}
try {
setIsSubmittingFeedback(true);
await axios.post(
'/portal-access/feedback',
{
message: feedbackMessage,
},
{
headers: {
'x-viewer-session': viewerToken,
},
},
);
setFeedbackMessage('');
setFeedbackSuccess('Masukan berhasil dikirim ke admin.');
await loadFeedbackHistory(viewerToken);
} catch (error) {
setFeedbackError(
getErrorMessage(
error,
'Masukan belum berhasil dikirim. Coba lagi sebentar.',
),
);
} finally {
setIsSubmittingFeedback(false);
}
};
return (
<>
<Head>
<title>{getPageTitle('Viewer PDF')}</title>
</Head>
<div className='min-h-screen bg-[#F3F6FB] text-slate-900'>
<div className='border-b border-slate-200 bg-white/90 backdrop-blur'>
<div className='mx-auto flex w-full max-w-7xl items-center justify-between gap-4 px-6 py-4 lg:px-10'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.28em] text-sky-700'>
Viewer Aman
</p>
<h1 className='text-xl font-bold text-slate-950'>Akses halaman PDF yang ditugaskan</h1>
</div>
<BaseButton
label='Kembali ke login'
icon={mdiArrowLeft}
color='light'
outline
onClick={handleExitViewer}
/>
</div>
</div>
<div className='mx-auto w-full max-w-7xl px-6 py-8 lg:px-10'>
{isPageLoading ? <LoadingSpinner /> : null}
{!isPageLoading && pageError ? (
<CardBox className='border border-red-200 bg-red-50'>
<div className='space-y-4'>
<div className='flex items-center gap-3 text-red-700'>
<BaseIcon path={mdiShieldLockOutline} size={24} />
<h2 className='text-xl font-semibold'>Akses viewer tidak tersedia</h2>
</div>
<p className='text-sm leading-6 text-red-700'>{pageError}</p>
<div>
<BaseButton label='Kembali ke halaman login' color='danger' onClick={handleExitViewer} />
</div>
</div>
</CardBox>
) : null}
{!isPageLoading && !pageError && session ? (
<div className='space-y-6'>
<div className='grid gap-6 xl:grid-cols-[1.15fr,0.85fr]'>
<CardBox className='border-transparent bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 text-white shadow-[0_22px_60px_rgba(15,23,42,0.2)]'>
<div className='flex flex-wrap items-start justify-between gap-5'>
<div className='space-y-4'>
<p className='inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.22em] text-sky-200'>
<BaseIcon path={mdiShieldLockOutline} size={16} />
Satu halaman terkunci
</p>
<div>
<h2 className='text-3xl font-bold leading-tight text-white'>
Halo, {session.viewer.name}
</h2>
<p className='mt-2 max-w-2xl text-sm leading-7 text-slate-300'>
Sistem sudah memverifikasi akun Anda. Di bawah ini hanya ditampilkan
halaman PDF yang di-assign admin untuk Anda.
</p>
</div>
</div>
<div className='rounded-3xl border border-white/10 bg-white/10 px-5 py-4 text-right'>
<p className='text-xs uppercase tracking-[0.24em] text-sky-200'>Halaman aktif</p>
<p className='mt-2 text-4xl font-black text-white'>
{session.assignment.pageNumber}
</p>
</div>
</div>
<div className='mt-8 grid gap-4 sm:grid-cols-3'>
<div className='rounded-3xl border border-white/10 bg-white/5 p-4'>
<div className='mb-3 inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-sky-500/15 text-sky-300'>
<BaseIcon path={mdiFilePdfBox} size={24} />
</div>
<p className='text-sm font-semibold text-white'>{assignmentTitle}</p>
<p className='mt-2 text-sm leading-6 text-slate-300'>
{session.assignment.pdfDocument?.totalPages
? `${session.assignment.pdfDocument.totalPages} halaman total`
: 'Total halaman belum diisi admin'}
</p>
</div>
<div className='rounded-3xl border border-white/10 bg-white/5 p-4'>
<div className='mb-3 inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-sky-500/15 text-sky-300'>
<BaseIcon path={mdiCheckCircleOutline} size={24} />
</div>
<p className='text-sm font-semibold text-white'>Aturan akses</p>
<p className='mt-2 text-sm leading-6 text-slate-300'>
{session.assignment.ruleName || 'Assignment aktif'}
</p>
</div>
<div className='rounded-3xl border border-white/10 bg-white/5 p-4'>
<div className='mb-3 inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-sky-500/15 text-sky-300'>
<BaseIcon path={mdiCommentTextOutline} size={24} />
</div>
<p className='text-sm font-semibold text-white'>Catatan perubahan</p>
<p className='mt-2 text-sm leading-6 text-slate-300'>
Gunakan formulir di bawah jika ada data pada halaman ini yang perlu dikoreksi.
</p>
</div>
</div>
</CardBox>
<CardBox>
<div className='space-y-5'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.24em] text-sky-700'>Ringkasan akses</p>
<h3 className='mt-2 text-2xl font-bold text-slate-950'>Informasi halaman yang Anda buka</h3>
</div>
<div className='space-y-4 text-sm leading-7 text-slate-600'>
<div className='rounded-3xl border border-slate-200 bg-slate-50 p-4'>
<p className='font-semibold text-slate-900'>Dokumen PDF</p>
<p>{assignmentTitle}</p>
</div>
<div className='rounded-3xl border border-slate-200 bg-slate-50 p-4'>
<p className='font-semibold text-slate-900'>Nomor halaman</p>
<p>Halaman {session.assignment.pageNumber}</p>
</div>
<div className='rounded-3xl border border-slate-200 bg-slate-50 p-4'>
<p className='font-semibold text-slate-900'>Mulai berlaku</p>
<p>{formatDate(session.assignment.effectiveFrom)}</p>
</div>
</div>
</div>
</CardBox>
</div>
<div className='grid gap-6 xl:grid-cols-[1.15fr,0.85fr]'>
<CardBox>
<div className='space-y-5'>
<div className='flex flex-wrap items-center justify-between gap-3'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.24em] text-sky-700'>Halaman PDF</p>
<h3 className='mt-1 text-2xl font-bold text-slate-950'>Preview halaman yang diizinkan</h3>
</div>
<div className='rounded-full bg-slate-100 px-4 py-2 text-sm font-semibold text-slate-700'>
Halaman {session.assignment.pageNumber}
</div>
</div>
{isPdfLoading ? <LoadingSpinner /> : null}
{pdfError ? (
<div className='rounded-3xl border border-red-200 bg-red-50 p-4 text-sm text-red-700'>
{pdfError}
</div>
) : null}
<div className='overflow-hidden rounded-[28px] border border-slate-200 bg-slate-50 p-3 shadow-inner'>
<div className='overflow-auto rounded-[22px] bg-white p-3'>
<canvas
ref={canvasRef}
onContextMenu={(event) => event.preventDefault()}
className='mx-auto block rounded-2xl shadow-[0_12px_40px_rgba(15,23,42,0.12)]'
/>
</div>
</div>
</div>
</CardBox>
<div className='space-y-6'>
<CardBox>
<div className='space-y-4'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.24em] text-sky-700'>Masukan perubahan data</p>
<h3 className='mt-1 text-2xl font-bold text-slate-950'>Kirim feedback ke admin</h3>
<p className='mt-2 text-sm leading-6 text-slate-600'>
Jelaskan data yang perlu diperbarui, misalnya ejaan nama, nomor,
atau isi lain pada halaman PDF ini.
</p>
</div>
<form onSubmit={handleSubmitFeedback} className='space-y-1'>
<FormField
label='Masukan Anda'
help='Tulis detail perubahan sejelas mungkin agar admin dapat menindaklanjuti.'
hasTextareaHeight
>
<textarea
value={feedbackMessage}
onChange={(event) => setFeedbackMessage(event.target.value)}
placeholder='Contoh: Nama pada halaman ini seharusnya PT Sinar Jaya, bukan PT Sinar Raya.'
/>
</FormField>
{feedbackError ? (
<div className='rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700'>
{feedbackError}
</div>
) : null}
{feedbackSuccess ? (
<div className='rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700'>
{feedbackSuccess}
</div>
) : null}
<BaseButton
type='submit'
color='info'
label={isSubmittingFeedback ? 'Mengirim...' : 'Kirim masukan'}
className='w-full justify-center'
disabled={isSubmittingFeedback}
/>
</form>
</div>
</CardBox>
<CardBox>
<div className='space-y-4'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.24em] text-sky-700'>Riwayat masukan</p>
<h3 className='mt-1 text-xl font-bold text-slate-950'>Masukan terakhir Anda</h3>
</div>
{feedbackItems.length === 0 ? (
<div className='rounded-3xl border border-dashed border-slate-200 bg-slate-50 p-5 text-sm leading-6 text-slate-500'>
Belum ada masukan yang dikirim. Jika ada perubahan data pada halaman ini,
kirimkan lewat formulir di atas.
</div>
) : (
<div className='space-y-4'>
{feedbackItems.map((item) => (
<div
key={item.id}
className='rounded-3xl border border-slate-200 bg-slate-50 p-4'
>
<div className='mb-3 flex flex-wrap items-center justify-between gap-3'>
<span
className={`inline-flex rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] ${getStatusStyles(item.status)}`}
>
{item.status}
</span>
<span className='text-xs text-slate-500'>
{formatDate(item.submittedAt)}
</span>
</div>
<p className='text-sm leading-7 text-slate-700'>{item.message}</p>
{item.adminNote ? (
<div className='mt-4 rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm leading-6 text-sky-900'>
<p className='mb-1 font-semibold'>Catatan admin</p>
<p>{item.adminNote}</p>
</div>
) : null}
</div>
))}
</div>
)}
</div>
</CardBox>
</div>
</div>
</div>
) : null}
</div>
</div>
</>
);
}
PdfViewerPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};