DONE: add portal setting endpoint for admin to update

This commit is contained in:
ISMAIL MASSERAN
2026-04-23 11:40:44 +08:00
parent 051ec7f48f
commit e0b8ea77eb
12 changed files with 518 additions and 78 deletions
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers\API\v1\Admin\PortalSettings;
use App\Http\Controllers\Controller;
use App\Models\PortalSetting;
class GetController extends Controller
{
public function __invoke()
{
$setting = PortalSetting::singleton();
$defaults = PortalSetting::defaults();
return response()->json([
'status' => 'success',
'data' => [
'home_mat_label' => $setting->home_mat_label ?? $defaults['home_mat_label'],
'home_banner_image_path' => $setting->home_banner_image_path ?? $defaults['home_banner_image_path'],
'zoom_meeting_label' => $setting->zoom_meeting_label ?? $defaults['zoom_meeting_label'],
'zoom_subtitle' => $setting->zoom_subtitle ?? $defaults['zoom_subtitle'],
'zoom_image_url' => $setting->zoom_image_url ?? $defaults['zoom_image_url'],
'zoom_meeting_url' => $setting->zoom_meeting_url ?? $defaults['zoom_meeting_url'],
],
]);
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\API\v1\Admin\PortalSettings;
use App\Http\Controllers\Controller;
use App\Models\PortalSetting;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class UpdateController extends Controller
{
public function __invoke(Request $request)
{
$setting = PortalSetting::singleton();
$validated = $request->validate([
'home_mat_label' => 'nullable|string|max:255',
'zoom_meeting_label' => 'nullable|string|max:255',
'zoom_subtitle' => 'nullable|string|max:2000',
'zoom_image_url' => 'nullable|string|max:2048',
'zoom_meeting_url' => 'nullable|string|max:2048',
'home_banner_image' => 'nullable|file|mimes:jpg,jpeg,png,webp|max:5120',
]);
if ($request->hasFile('home_banner_image')) {
$file = $request->file('home_banner_image');
$ext = strtolower($file->getClientOriginalExtension() ?: 'jpg');
$name = 'banner.' . $ext;
$relativePath = 'portal/' . $name;
Storage::disk('public')->putFileAs('portal', $file, $name);
$validated['home_banner_image_path'] = '/storage/' . $relativePath;
}
$setting->fill([
'home_mat_label' => $validated['home_mat_label'] ?? $setting->home_mat_label,
'home_banner_image_path' => $validated['home_banner_image_path'] ?? $setting->home_banner_image_path,
'zoom_meeting_label' => $validated['zoom_meeting_label'] ?? $setting->zoom_meeting_label,
'zoom_subtitle' => $validated['zoom_subtitle'] ?? $setting->zoom_subtitle,
'zoom_image_url' => $validated['zoom_image_url'] ?? $setting->zoom_image_url,
'zoom_meeting_url' => $validated['zoom_meeting_url'] ?? $setting->zoom_meeting_url,
]);
$setting->save();
return response()->json([
'status' => 'success',
'message' => 'Portal settings updated.',
]);
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers\API\v1\PortalSettings;
use App\Http\Controllers\Controller;
use App\Models\PortalSetting;
class GetController extends Controller
{
public function __invoke()
{
$setting = PortalSetting::singleton();
$defaults = PortalSetting::defaults();
return response()->json([
'status' => 'success',
'data' => [
'home_mat_label' => $setting->home_mat_label ?? $defaults['home_mat_label'],
'home_banner_image_path' => $setting->home_banner_image_path ?? $defaults['home_banner_image_path'],
'zoom_meeting_label' => $setting->zoom_meeting_label ?? $defaults['zoom_meeting_label'],
'zoom_subtitle' => $setting->zoom_subtitle ?? $defaults['zoom_subtitle'],
'zoom_image_url' => $setting->zoom_image_url ?? $defaults['zoom_image_url'],
'zoom_meeting_url' => $setting->zoom_meeting_url ?? $defaults['zoom_meeting_url'],
],
]);
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PortalSetting extends Model
{
protected $table = 'portal_settings';
protected $fillable = [
'home_mat_label',
'home_banner_image_path',
'zoom_meeting_label',
'zoom_subtitle',
'zoom_image_url',
'zoom_meeting_url',
];
public static function defaults(): array
{
return [
'home_mat_label' => 'MAT KE-29',
'home_banner_image_path' => '/images/banner.jpeg',
'zoom_meeting_label' => 'KoPKB KALI KE-29/2025',
'zoom_subtitle' => 'Sertai Mesyuarat Agung Tahunan KoPKB kali ke-29/2025 melalui pautan rasmi di bawah.',
'zoom_image_url' => 'https://th.bing.com/th/id/OIP.TGLvYXfe4xq5MTZe4UnTDgHaEy?w=255&h=180&c=7&r=0&o=5&dpr=1.8&pid=1.7',
'zoom_meeting_url' => 'https://meet.google.com/paa-tpck-ssq',
];
}
public static function singleton(): self
{
$defaults = static::defaults();
/** @var self $record */
$record = static::query()->first();
if ($record) {
return $record;
}
return static::query()->create($defaults);
}
}
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreatePortalSettingsTable extends Migration
{
public function up(): void
{
Schema::create('portal_settings', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('home_mat_label')->nullable();
$table->string('home_banner_image_path')->nullable();
$table->string('zoom_meeting_label')->nullable();
$table->text('zoom_subtitle')->nullable();
$table->string('zoom_image_url')->nullable();
$table->string('zoom_meeting_url')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('portal_settings');
}
}
+1 -1
View File
File diff suppressed because one or more lines are too long
@@ -54,6 +54,10 @@
<a href="#"><b>Log Aktiviti</b></a>
</router-link>
<router-link :to="{ name: 'Tetapan Portal' }" v-if="data.user.role == 1" tag="li">
<a href="#"><b>Tetapan Portal</b></a>
</router-link>
</ul>
<ul class="nav navbar-right navbar-nav">
@@ -0,0 +1,143 @@
<template>
<div class="container col-md-10 col-md-offset-1">
<h4>Tetapan Portal (MAT / Mesyuarat)</h4>
<div class="panel panel-default">
<div class="panel-body">
<div class="form-group">
<label>Label pautan (Home) contoh: MAT KE-29</label>
<input v-model="form.home_mat_label" type="text" class="form-control" placeholder="MAT KE-29">
</div>
<div class="form-group">
<label>Banner (Home)</label>
<!-- <div v-if="form.home_banner_image_path" class="help-block">
Semasa: <code>{{ form.home_banner_image_path }}</code>
</div> -->
<input type="file" class="form-control" @change="onBannerChange" accept="image/*">
</div>
<hr>
<div class="form-group">
<label>Label KoPKB (Zoom) contoh: KoPKB KALI KE-29/2025</label>
<input v-model="form.zoom_meeting_label" type="text" class="form-control" placeholder="KoPKB KALI KE-29/2025">
</div>
<div class="form-group">
<label>Ayat penerangan (Zoom)</label>
<textarea v-model="form.zoom_subtitle" class="form-control" rows="3"
placeholder="Sertai Mesyuarat Agung Tahunan..."></textarea>
</div>
<div class="form-group">
<label>Gambar</label>
<!-- <div class="help-block">
Guna path seperti <code>/storage/...</code> atau <code>/images/...</code>. Link luar
(<code>https://...</code>) akan diabaikan.
</div> -->
<input v-model="form.zoom_image_url" type="text" class="form-control"
placeholder="/storage/portal/meeting.png" disabled>
</div>
<div class="form-group">
<label>Pautan mesyuarat (Zoom) Google Meet</label>
<input v-model="form.zoom_meeting_url" type="text" class="form-control"
placeholder="https://meet.google.com/...">
</div>
<button class="btn btn-primary" :disabled="saving" @click="save">
<i class="fa fa-save"></i> {{ saving ? 'Menyimpan...' : 'Simpan' }}
</button>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
loading: false,
saving: false,
bannerFile: null,
form: {
home_mat_label: "",
home_banner_image_path: "",
zoom_meeting_label: "",
zoom_subtitle: "",
zoom_image_url: "",
zoom_meeting_url: ""
}
};
},
created() {
this.util.setTitle("MyKoPKB - Tetapan Portal");
this.fetch();
},
methods: {
onBannerChange(e) {
const files = e && e.target ? e.target.files : null;
this.bannerFile = files && files.length ? files[0] : null;
},
async fetch() {
this.loading = true;
try {
this.util.setAuthorization();
const res = await axios.get(config.API + "admin/portal-settings");
if (res && res.data && res.data.status === "success") {
const d = res.data.data || {};
this.form = {
home_mat_label: d.home_mat_label || "",
home_banner_image_path: d.home_banner_image_path || "",
zoom_meeting_label: d.zoom_meeting_label || "",
zoom_subtitle: d.zoom_subtitle || "",
zoom_image_url: d.zoom_image_url || "",
zoom_meeting_url: d.zoom_meeting_url || ""
};
} else {
this.util.showResult(res, "error");
}
} catch (e) {
this.util.showResult(e, "error");
} finally {
this.loading = false;
}
},
async save() {
if (this.saving) return;
this.saving = true;
try {
this.util.setAuthorization();
const fd = new FormData();
fd.append("home_mat_label", this.form.home_mat_label || "");
fd.append("zoom_meeting_label", this.form.zoom_meeting_label || "");
fd.append("zoom_subtitle", this.form.zoom_subtitle || "");
// zoom_image_url is intentionally not editable from UI (uses default icon / design).
fd.append("zoom_meeting_url", this.form.zoom_meeting_url || "");
if (this.bannerFile) fd.append("home_banner_image", this.bannerFile);
const res = await axios.post(config.API + "admin/portal-settings", fd, {
headers: { "Content-Type": "multipart/form-data" }
});
if (res && res.data && res.data.status === "success") {
this.util.notify("Berjaya simpan tetapan portal.", "success");
this.bannerFile = null;
await this.fetch();
} else {
this.util.showResult(res, "error");
}
} catch (e) {
this.util.showResult(e, "error");
} finally {
this.saving = false;
}
}
}
};
</script>
@@ -5,7 +5,7 @@
<div class="home-section-container home-hero-section">
<section class="home-hero-wrap">
<div class="hero-banner home-hero-banner">
<img src="/images/banner-mat28-new.jpg" alt="Banner" class="hero-banner-image">
<img :src="bannerUrl" alt="Banner" class="hero-banner-image">
<div class="home-hero-banner-shine" aria-hidden="true"></div>
</div>
<div class="hero-banner-caption home-hero-caption">
@@ -192,11 +192,18 @@ export default {
claimCode: "",
claimExpiresAt: "",
claimError: "",
matLinkUrl: "https://linktr.ee/kopkb"
matLinkUrl: "https://linktr.ee/kopkb",
portalSettings: null,
};
},
computed: {
bannerUrl() {
const p = this.portalSettings && this.portalSettings.home_banner_image_path
? this.portalSettings.home_banner_image_path
: "/images/banner.jpeg";
return p;
},
/**
* Maya: boleh mengundi selepas daftar kehadiran. Fizikal: perlu disahkan pentadbir dahulu.
*/
@@ -231,6 +238,9 @@ export default {
const user = this.data?.user || {};
const election = this.data?.election || {};
const noAnggota = user.no_anggota;
const matLabel = (this.portalSettings && this.portalSettings.home_mat_label)
? this.portalSettings.home_mat_label
: "MAT KE-29";
const links = [
{
key: "calon",
@@ -243,7 +253,7 @@ export default {
},
{
key: "mat28",
title: "MAT KE-28",
title: matLabel,
desc: "Pautan luar ke maklumat mesyuarat dan sumber berkaitan",
icon: "fa-globe",
href: this.matLinkUrl,
@@ -339,6 +349,7 @@ export default {
mounted() {
this.syncBarcodeFromUser();
this.fetchPortalSettings();
},
watch: {
@@ -348,6 +359,18 @@ export default {
},
methods: {
async fetchPortalSettings() {
try {
this.util.setAuthorization();
const res = await axios.get(config.API + "portal-settings");
if (res && res.data && res.data.status === "success") {
this.portalSettings = res.data.data || null;
}
} catch (e) {
// Non-critical — fall back to defaults.
}
},
syncBarcodeFromUser() {
if (this.data?.user?.no_kp) {
this.noKp = this.data.user.no_kp;
@@ -1,38 +1,35 @@
<template>
<div class="row col-md-10 col-md-offset-1 zoom-page">
<div class="col-md-12">
<div class="zoom-page">
<div class="zoom-shell">
<div class="zoom-hero-card">
<div class="zoom-section-kicker">Pautan Mesyuarat</div>
<h3 class="zoom-title">SELAMAT DATANG KE MESYUARAT AGUNG TAHUNAN</h3>
<p class="zoom-subtitle">
Sertai Mesyuarat Agung Tahunan KoPKB kali ke-28/2024 melalui pautan rasmi di bawah.
</p>
<h3 class="zoom-title">Selamat Datang</h3>
<p class="zoom-subtitle">{{ zoomSubtitle }}</p>
</div>
<div class="zoom-main-card">
<div class="centered-container zoom-image-section">
<div class="image-container zoom-image-card">
<img :src="imageUrl" alt="meeting apps" class="zoom-image" />
</div>
</div>
<div class="zoom-meeting-badge">{{ zoomMeetingLabel }}</div>
<div class="centered-container zoom-content-section">
<div class="zoom-meeting-badge">
KoPKB KALI KE-28/2024
</div>
<h4 class="zoom-content-title">Google Meet Rasmi</h4>
<p class="zoom-content-text">
Pastikan anda telah mendaftar kehadiran dan bersedia dengan sambungan internet yang stabil sebelum menyertai
mesyuarat.
</p>
<h4 class="zoom-content-title">Pautan Mesyuarat Rasmi</h4>
<p class="zoom-content-text">
Pastikan anda telah mendaftar kehadiran dan bersedia dengan sambungan internet yang stabil sebelum menyertai mesyuarat.
</p>
<div class="zoom-action-panel">
<button class="btn-interactive" @click="navigateToURL" type="button">
<i class="fa fa-video-camera"></i> Klik pada pautan berikut
</button>
<div class="zoom-link-note">
Pautan akan dibuka terus ke sesi mesyuarat rasmi.
</div>
<div class="zoom-action-panel">
<button class="btn-interactive" @click="navigateToURL" type="button">
<i class="fa fa-video-camera"></i> Buka pautan mesyuarat
</button>
<button class="btn-secondary" @click="copyMeetingUrl" type="button">
<i class="fa fa-clipboard"></i> Salin pautan
</button>
<div class="zoom-link-preview" v-if="meetingUrl">
<span class="zoom-link-preview__label">Pautan:</span>
<span class="zoom-link-preview__value">{{ meetingUrl }}</span>
</div>
<div class="zoom-link-note">
Pautan akan dibuka terus ke sesi mesyuarat rasmi.
</div>
</div>
</div>
@@ -44,14 +41,73 @@
export default {
data() {
return {
imageUrl: 'https://th.bing.com/th/id/OIP.TGLvYXfe4xq5MTZe4UnTDgHaEy?w=255&h=180&c=7&r=0&o=5&dpr=1.8&pid=1.7',
meetingUrl: 'https://meet.google.com/paa-tpck-ssq'
meetingUrl: 'https://meet.google.com/paa-tpck-ssq',
portalSettings: null,
};
},
computed: {
zoomMeetingLabel() {
return (this.portalSettings && this.portalSettings.zoom_meeting_label)
? this.portalSettings.zoom_meeting_label
: "KoPKB KALI KE-29/2025";
},
zoomSubtitle() {
return (this.portalSettings && this.portalSettings.zoom_subtitle)
? this.portalSettings.zoom_subtitle
: "Sertai Mesyuarat Agung Tahunan KoPKB kali ke-29/2025 melalui pautan rasmi di bawah.";
}
},
mounted() {
this.fetchPortalSettings();
},
methods: {
async fetchPortalSettings() {
try {
this.util.setAuthorization();
const res = await axios.get(config.API + "portal-settings");
if (res && res.data && res.data.status === "success") {
this.portalSettings = res.data.data || null;
if (this.portalSettings.zoom_meeting_url) this.meetingUrl = this.portalSettings.zoom_meeting_url;
}
} catch (e) {
// Non-critical — fall back to defaults.
}
},
navigateToURL() {
window.open(this.meetingUrl, '_blank');
},
async copyMeetingUrl() {
const text = String(this.meetingUrl || "").trim();
if (!text) return;
try {
if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
this.util.notify("Pautan disalin.", "success");
return;
}
} catch (e) {
// fall through
}
try {
const el = document.createElement("textarea");
el.value = text;
el.setAttribute("readonly", "");
el.style.position = "absolute";
el.style.left = "-9999px";
document.body.appendChild(el);
el.select();
document.execCommand("copy");
document.body.removeChild(el);
this.util.notify("Pautan disalin.", "success");
} catch (e) {
this.util.notify("Tidak dapat salin pautan.", "error");
}
}
}
};
@@ -65,6 +121,12 @@ export default {
margin-bottom: 36px;
}
.zoom-shell {
max-width: 1040px;
margin: 0 auto;
padding: 0 12px;
}
.zoom-hero-card {
margin-bottom: 22px;
padding: 26px 28px;
@@ -87,8 +149,8 @@ export default {
.zoom-title {
margin: 0 0 10px;
color: #17324d;
font-size: 2.8rem;
font-weight: 700;
font-size: 3rem;
font-weight: 800;
line-height: 1.3;
}
@@ -101,49 +163,19 @@ export default {
}
.zoom-main-card {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
gap: 24px;
padding: 24px;
padding: 26px 28px;
border-radius: 22px;
background: #fff;
border: 1px solid #e2ebf5;
box-shadow: 0 16px 34px rgba(35, 64, 97, 0.08);
}
.centered-container {
text-align: center;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 40vh;
}
.zoom-image-card {
width: 100%;
overflow: hidden;
border-radius: 18px;
border: 1px solid #dbe7f3;
background: #f4f8fd;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
}
.zoom-image {
width: 100%;
max-height: 420px;
object-fit: cover;
display: block;
}
.zoom-content-section {
align-items: flex-start;
text-align: left;
}
.zoom-meeting-badge {
margin-bottom: 12px;
padding: 8px 12px;
display: inline-flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
padding: 8px 14px;
border-radius: 999px;
background: #e8f1ff;
color: #215db8;
@@ -173,6 +205,10 @@ export default {
background: linear-gradient(180deg, #f8fbff 0%, #eef6ff 100%);
border: 1px solid #d9e8fb;
box-shadow: 0 12px 28px rgba(35, 64, 97, 0.07);
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
align-items: center;
}
.btn-interactive {
@@ -187,6 +223,7 @@ export default {
transition: background-color 0.3s ease, transform 0.2s ease, box-shadow 0.2s ease;
outline: none;
box-shadow: 0 12px 24px rgba(13, 110, 253, 0.25);
width: 100%;
}
.btn-interactive:hover {
@@ -203,24 +240,66 @@ export default {
box-shadow: 0 0 5px 2px rgba(0, 123, 255, 0.5);
}
.btn-secondary {
padding: 14px 18px;
background: rgba(255, 255, 255, 0.7);
color: #17324d;
border: 1px solid rgba(23, 50, 77, 0.16);
border-radius: 999px;
font-size: 15px;
font-weight: 800;
cursor: pointer;
box-shadow: 0 10px 22px rgba(35, 64, 97, 0.08);
transition: transform 0.2s ease, box-shadow 0.2s ease;
white-space: nowrap;
}
.btn-secondary:hover {
transform: translateY(-1px);
box-shadow: 0 14px 26px rgba(35, 64, 97, 0.12);
}
.zoom-link-preview {
grid-column: 1 / -1;
display: flex;
gap: 10px;
align-items: baseline;
padding: 10px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(33, 93, 184, 0.16);
}
.zoom-link-preview__label {
flex-shrink: 0;
font-weight: 900;
color: #215db8;
letter-spacing: 0.04em;
text-transform: uppercase;
font-size: 1.05rem;
}
.zoom-link-preview__value {
color: #17324d;
font-weight: 700;
word-break: break-all;
}
.zoom-link-note {
grid-column: 1 / -1;
margin-top: 12px;
color: #5f7388;
font-size: 1.3rem;
}
@media (max-width: 991px) {
.zoom-main-card {
.zoom-action-panel {
grid-template-columns: 1fr;
}
.zoom-content-section {
align-items: center;
text-align: center;
}
}
@media (max-width: 767px) {
.zoom-hero-card,
.zoom-main-card {
padding: 18px;
@@ -228,7 +307,7 @@ export default {
}
.zoom-title {
font-size: 2.2rem;
font-size: 2.35rem;
}
.zoom-subtitle,
+7
View File
@@ -60,6 +60,7 @@ import ManageBelianKomoditi from "./components/demo/admin/admin/beliankomoditi.v
import ManageKontrakMurabahah from "./components/demo/admin/admin/kontrakmurabahah.vue";
import CetakPermohonan from "./components/demo/admin/admin/cetakpermohonan.vue";
import ManagePenyata from "./components/demo/admin/admin/penyata-anggota.vue";
import AdminPortalSettings from "./components/demo/admin/portal-settings/index.vue";
const default_component = {
template: "<div>Not found: {{ $route.path }}</div>",
@@ -184,6 +185,12 @@ export default [
],
},
{
path: "portal-settings",
component: AdminPortalSettings,
name: "Tetapan Portal",
},
{
path: "position",
component: ManagePosition,
+3
View File
@@ -36,6 +36,7 @@ Route::prefix('v1')->group(function () { //Version 1 of my Rest API
Route::post('election/vote', 'API\v1\Election\VoteController')->middleware(['fizikal_registration_verified', 'isvoted']);
Route::get('election/result', 'API\v1\Election\ResultController')->middleware('has_voted');
Route::post('allowance/code', 'API\v1\Allowance\ClaimCodeController');
Route::get('portal-settings', 'API\v1\PortalSettings\GetController');
Route::post('voter/logout', 'API\v1\Voter\LogoutController');
});
@@ -57,6 +58,8 @@ Route::prefix('v1')->group(function () { //Version 1 of my Rest API
Route::get('information', 'API\v1\Admin\InformationController');
Route::get('logout', 'API\v1\Admin\LogoutController');
Route::get('activity-log', 'API\v1\Admin\ActivityLog\IndexController');
Route::get('portal-settings', 'API\v1\Admin\PortalSettings\GetController');
Route::post('portal-settings', 'API\v1\Admin\PortalSettings\UpdateController');
Route::post('impersonate', 'API\v1\Admin\Impersonate\StartController')->middleware('main_admin');
Route::post('impersonate/leave', 'API\v1\Admin\Impersonate\LeaveController');
Route::get('{id}', 'API\v1\Admin\GetController@show');