hery
This commit is contained in:
parent
5f811c50b4
commit
2130d23ff4
@ -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');
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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) {
|
module.exports = function(sequelize, DataTypes) {
|
||||||
const page_access_rules = sequelize.define(
|
const page_access_rules = sequelize.define(
|
||||||
'page_access_rules',
|
'page_access_rules',
|
||||||
@ -98,6 +92,13 @@ effective_until: {
|
|||||||
constraints: false,
|
constraints: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
db.page_access_rules.belongsTo(db.users, {
|
||||||
|
as: 'user',
|
||||||
|
foreignKey: {
|
||||||
|
name: 'userId',
|
||||||
|
},
|
||||||
|
constraints: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,6 @@ const config = require('../../config');
|
|||||||
const providers = config.providers;
|
const providers = config.providers;
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
const moment = require('moment');
|
|
||||||
|
|
||||||
module.exports = function(sequelize, DataTypes) {
|
module.exports = function(sequelize, DataTypes) {
|
||||||
const users = sequelize.define(
|
const users = sequelize.define(
|
||||||
@ -154,6 +153,14 @@ provider: {
|
|||||||
constraints: false,
|
constraints: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
db.users.hasMany(db.page_access_rules, {
|
||||||
|
as: 'page_access_rules_user',
|
||||||
|
foreignKey: {
|
||||||
|
name: 'userId',
|
||||||
|
},
|
||||||
|
constraints: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
//end loop
|
//end loop
|
||||||
@ -191,8 +198,8 @@ provider: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
users.beforeCreate((users, options) => {
|
users.beforeCreate((users) => {
|
||||||
users = trimStringFields(users);
|
trimStringFields(users);
|
||||||
|
|
||||||
if (users.provider !== providers.LOCAL && Object.values(providers).indexOf(users.provider) > -1) {
|
if (users.provider !== providers.LOCAL && Object.values(providers).indexOf(users.provider) > -1) {
|
||||||
users.emailVerified = true;
|
users.emailVerified = true;
|
||||||
@ -212,8 +219,8 @@ provider: {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
users.beforeUpdate((users, options) => {
|
users.beforeUpdate((users) => {
|
||||||
users = trimStringFields(users);
|
trimStringFields(users);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,6 @@ const passport = require('passport');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const bodyParser = require('body-parser');
|
const bodyParser = require('body-parser');
|
||||||
const db = require('./db/models');
|
|
||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
const swaggerUI = require('swagger-ui-express');
|
const swaggerUI = require('swagger-ui-express');
|
||||||
const swaggerJsDoc = require('swagger-jsdoc');
|
const swaggerJsDoc = require('swagger-jsdoc');
|
||||||
@ -18,8 +17,7 @@ const sqlRoutes = require('./routes/sql');
|
|||||||
const pexelsRoutes = require('./routes/pexels');
|
const pexelsRoutes = require('./routes/pexels');
|
||||||
|
|
||||||
const openaiRoutes = require('./routes/openai');
|
const openaiRoutes = require('./routes/openai');
|
||||||
|
const portalAccessRoutes = require('./routes/portalAccess');
|
||||||
|
|
||||||
|
|
||||||
const usersRoutes = require('./routes/users');
|
const usersRoutes = require('./routes/users');
|
||||||
|
|
||||||
@ -88,6 +86,7 @@ app.use(bodyParser.json());
|
|||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/file', fileRoutes);
|
app.use('/api/file', fileRoutes);
|
||||||
app.use('/api/pexels', pexelsRoutes);
|
app.use('/api/pexels', pexelsRoutes);
|
||||||
|
app.use('/api/portal-access', portalAccessRoutes);
|
||||||
app.enable('trust proxy');
|
app.enable('trust proxy');
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
142
backend/src/routes/portalAccess.js
Normal file
142
backend/src/routes/portalAccess.js
Normal 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;
|
||||||
741
backend/src/services/portalAccess.js
Normal file
741
backend/src/services/portalAccess.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -1,6 +1,5 @@
|
|||||||
import React, {useEffect, useRef} from 'react'
|
import React, {useEffect, useRef, useState} from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useState } from 'react'
|
|
||||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||||
import BaseDivider from './BaseDivider'
|
import BaseDivider from './BaseDivider'
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import React, { ReactNode, useEffect } from 'react'
|
import React, { ReactNode, useEffect, useState } from 'react'
|
||||||
import { useState } from 'react'
|
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||||
import menuAside from '../menuAside'
|
import menuAside from '../menuAside'
|
||||||
|
|||||||
@ -7,6 +7,14 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
icon: icon.mdiViewDashboardOutline,
|
icon: icon.mdiViewDashboardOutline,
|
||||||
label: 'Dashboard',
|
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',
|
href: '/users/users-list',
|
||||||
|
|||||||
@ -1,166 +1,352 @@
|
|||||||
|
import {
|
||||||
import React, { useEffect, useState } from 'react';
|
mdiAccountKeyOutline,
|
||||||
import type { ReactElement } from 'react';
|
mdiArrowRight,
|
||||||
|
mdiCheckCircleOutline,
|
||||||
|
mdiCommentTextOutline,
|
||||||
|
mdiFilePdfBox,
|
||||||
|
mdiLogin,
|
||||||
|
mdiShieldLockOutline,
|
||||||
|
} from '@mdi/js';
|
||||||
|
import axios from 'axios';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
|
||||||
import BaseButton from '../components/BaseButton';
|
import BaseButton from '../components/BaseButton';
|
||||||
|
import BaseIcon from '../components/BaseIcon';
|
||||||
import CardBox from '../components/CardBox';
|
import CardBox from '../components/CardBox';
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
import FormField from '../components/FormField';
|
||||||
import LayoutGuest from '../layouts/Guest';
|
|
||||||
import BaseDivider from '../components/BaseDivider';
|
|
||||||
import BaseButtons from '../components/BaseButtons';
|
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import { useAppSelector } from '../stores/hooks';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
import { useRouter } from 'next/router';
|
||||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
|
||||||
|
|
||||||
|
const VIEWER_SESSION_STORAGE_KEY = 'viewerAccessToken';
|
||||||
|
|
||||||
export default function Starter() {
|
const featureCards = [
|
||||||
const [illustrationImage, setIllustrationImage] = useState({
|
{
|
||||||
src: undefined,
|
icon: mdiShieldLockOutline,
|
||||||
photographer: undefined,
|
title: 'Akses terkunci per user',
|
||||||
photographer_url: undefined,
|
description: 'Setiap user login dengan nama dan nomor unik yang sudah ditentukan admin.',
|
||||||
})
|
},
|
||||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
{
|
||||||
const [contentType, setContentType] = useState('image');
|
icon: mdiFilePdfBox,
|
||||||
const [contentPosition, setContentPosition] = useState('right');
|
title: 'Hanya 1 halaman',
|
||||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
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
|
function getErrorMessage(error: unknown, fallback: string) {
|
||||||
useEffect(() => {
|
if (axios.isAxiosError(error)) {
|
||||||
async function fetchData() {
|
return String(error.response?.data || error.message || fallback);
|
||||||
const image = await getPexelsImage();
|
}
|
||||||
const video = await getPexelsVideo();
|
|
||||||
setIllustrationImage(image);
|
|
||||||
setIllustrationVideo(video);
|
|
||||||
}
|
|
||||||
fetchData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const imageBlock = (image) => (
|
if (error instanceof Error) {
|
||||||
<div
|
return error.message;
|
||||||
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>
|
|
||||||
);
|
|
||||||
|
|
||||||
const videoBlock = (video) => {
|
return fallback;
|
||||||
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'>
|
export default function LandingPage() {
|
||||||
<video
|
const router = useRouter();
|
||||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
const [form, setForm] = useState({
|
||||||
autoPlay
|
name: '',
|
||||||
loop
|
uniqueNumber: '',
|
||||||
muted
|
});
|
||||||
>
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
Your browser does not support the video tag.
|
const [successMessage, setSuccessMessage] = useState('');
|
||||||
</video>
|
|
||||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
const handleViewerLogin = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
<a
|
event.preventDefault();
|
||||||
className='text-[8px]'
|
setErrorMessage('');
|
||||||
href={video?.user?.url}
|
setSuccessMessage('');
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
if (!form.name.trim() || !form.uniqueNumber.trim()) {
|
||||||
>
|
setErrorMessage('Nama dan nomor unik wajib diisi.');
|
||||||
Video by {video.user.name} on Pexels
|
return;
|
||||||
</a>
|
}
|
||||||
</div>
|
|
||||||
</div>)
|
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 (
|
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>
|
<Head>
|
||||||
<title>{getPageTitle('Starter Page')}</title>
|
<title>{getPageTitle('Portal Akses PDF')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<div className='min-h-screen bg-[#07111F] text-slate-100'>
|
||||||
<div
|
<div className='relative overflow-hidden'>
|
||||||
className={`flex ${
|
<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%)]' />
|
||||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
<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'>
|
||||||
} min-h-screen w-full`}
|
<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>
|
||||||
{contentType === 'image' && contentPosition !== 'background'
|
<p className='text-xs font-semibold uppercase tracking-[0.32em] text-sky-300'>
|
||||||
? imageBlock(illustrationImage)
|
Secure PDF Page Access
|
||||||
: null}
|
</p>
|
||||||
{contentType === 'video' && contentPosition !== 'background'
|
<p className='text-sm text-slate-300'>
|
||||||
? videoBlock(illustrationVideo)
|
Viewer aman untuk 1 halaman PDF per user.
|
||||||
: null}
|
</p>
|
||||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
</div>
|
||||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
<div className='flex items-center gap-3'>
|
||||||
<CardBoxComponentTitle title="Welcome to your Secure PDF Page Access app!"/>
|
<Link
|
||||||
|
href='/login'
|
||||||
<div className="space-y-3">
|
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'
|
||||||
<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
|
Masuk Admin
|
||||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
<BaseButtons>
|
|
||||||
<BaseButton
|
|
||||||
href='/login'
|
|
||||||
label='Login'
|
|
||||||
color='info'
|
|
||||||
className='w-full'
|
|
||||||
/>
|
|
||||||
|
|
||||||
</BaseButtons>
|
<main className='grid flex-1 items-center gap-10 lg:grid-cols-[1.05fr,0.95fr]'>
|
||||||
</CardBox>
|
<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>
|
||||||
</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>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
884
frontend/src/pages/pdf-access-center.tsx
Normal file
884
frontend/src/pages/pdf-access-center.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
573
frontend/src/pages/pdf-viewer.tsx
Normal file
573
frontend/src/pages/pdf-viewer.tsx
Normal 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>;
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user