From 5174e82e51bbde61f603afa84c138559093e6b27 Mon Sep 17 00:00:00 2001 From: Kwesi Banson Date: Tue, 22 Sep 2026 08:06:31 +0000 Subject: [PATCH] Updated ERP with new modules and features --- .../Controllers/ActivityLogController.php | 42 ++ .../Controllers/DailyReportController.php | 5 +- .../Controllers/LeaveRequestController.php | 2 +- .../ManagementDashboardController.php | 64 +++ .../NetworkOperatorsController.php | 49 +- app/Http/Controllers/ServerController.php | 63 +++ .../ServerCredentialController.php | 70 +++ app/Http/Controllers/VpnConfigController.php | 166 ++++++ app/Models/ActivityLog.php | 30 ++ app/Models/Client.php | 3 +- app/Models/ClientFile.php | 2 + app/Models/ClientNote.php | 2 + app/Models/DailyReport.php | 2 + app/Models/LeaveManagement.php | 2 + app/Models/LeaveRequest.php | 6 - app/Models/NetworkOperator.php | 8 + app/Models/NetworkOperatorDocument.php | 17 + app/Models/OfficeLocation.php | 2 + app/Models/OfficeLocationDocument.php | 2 + app/Models/SenderId.php | 7 +- app/Models/Senderidold.php | 3 +- app/Models/Server.php | 22 + app/Models/ShortCode.php | 3 + app/Models/StaffDependent.php | 2 + app/Models/StaffMember.php | 4 +- app/Models/VpnConfig.php | 17 +- app/Traits/LogsActivity.php | 65 +++ ...2026_09_15_181659_create_servers_table.php | 34 ++ ...00_create_password_access_audits_table.php | 31 ++ ..._09_15_201604_create_vpn_configs_table.php | 34 ++ ...add_crypto_params_to_vpn_configs_table.php | 42 ++ ...9_18_155053_create_activity_logs_table.php | 27 + ...missing_columns_to_activity_logs_table.php | 23 + ...reate_network_operator_documents_table.php | 42 ++ features.md | 33 ++ public/assets/js/mno-show.js | 274 ++++++++++ public/assets/js/vpn-index.js | 183 +++++++ resources/views/activity-logs/index.blade.php | 203 +++++++ resources/views/dashboard/index.blade.php | 4 +- resources/views/layouts/masterbeta.blade.php | 7 + .../views/layouts/partials/sidebar.blade.php | 41 +- resources/views/leave/my_leave.blade.php | 8 +- resources/views/management/index.blade.php | 200 +++++++ .../views/network_operators/index.blade.php | 7 +- .../partials/modals.blade.php | 258 +++++++++ .../views/network_operators/show.blade.php | 262 ++++----- resources/views/servers/index.blade.php | 496 +++++++++++++++++ resources/views/shortcodes/index.blade.php | 4 +- resources/views/staff/index.blade.php | 5 +- resources/views/vpns/index.blade copy.php | 491 +++++++++++++++++ resources/views/vpns/index.blade.php | 505 ++++++++++++++++++ routes/web.php | 26 + scratch.md | 9 + 53 files changed, 3706 insertions(+), 203 deletions(-) create mode 100644 app/Http/Controllers/ActivityLogController.php create mode 100644 app/Http/Controllers/ManagementDashboardController.php create mode 100644 app/Http/Controllers/ServerController.php create mode 100644 app/Http/Controllers/ServerCredentialController.php create mode 100644 app/Http/Controllers/VpnConfigController.php create mode 100644 app/Models/ActivityLog.php create mode 100644 app/Models/NetworkOperatorDocument.php create mode 100644 app/Models/Server.php create mode 100644 app/Traits/LogsActivity.php create mode 100644 database/migrations/2026_09_15_181659_create_servers_table.php create mode 100644 database/migrations/2026_09_15_181700_create_password_access_audits_table.php create mode 100644 database/migrations/2026_09_15_201604_create_vpn_configs_table.php create mode 100644 database/migrations/2026_09_15_220259_add_crypto_params_to_vpn_configs_table.php create mode 100644 database/migrations/2026_09_18_155053_create_activity_logs_table.php create mode 100644 database/migrations/2026_09_18_173530_add_missing_columns_to_activity_logs_table.php create mode 100644 database/migrations/2026_09_18_181328_create_network_operator_documents_table.php create mode 100644 features.md create mode 100644 public/assets/js/mno-show.js create mode 100644 public/assets/js/vpn-index.js create mode 100644 resources/views/activity-logs/index.blade.php create mode 100644 resources/views/management/index.blade.php create mode 100644 resources/views/network_operators/partials/modals.blade.php create mode 100644 resources/views/servers/index.blade.php create mode 100644 resources/views/vpns/index.blade copy.php create mode 100644 resources/views/vpns/index.blade.php diff --git a/app/Http/Controllers/ActivityLogController.php b/app/Http/Controllers/ActivityLogController.php new file mode 100644 index 0000000..e650fe5 --- /dev/null +++ b/app/Http/Controllers/ActivityLogController.php @@ -0,0 +1,42 @@ +latest(); + + // 1. Filter by Action + if ($request->filled('action_type')) { + $query->where('action', $request->action_type); + } + + // 2. Filter by Date Range + if ($request->filled('date_from')) { + $query->whereDate('created_at', '>=', $request->date_from); + } + if ($request->filled('date_to')) { + $query->whereDate('created_at', '<=', $request->date_to); + } + + // 3. Search by User or Entity Type + if ($request->filled('search')) { + $searchTerm = $request->search; + $query->where(function ($q) use ($searchTerm) { + $q->whereHas('user', function ($userQuery) use ($searchTerm) { + $userQuery->where('name', 'like', '%' . $searchTerm . '%'); + }) + ->orWhere('loggable_type', 'like', '%' . $searchTerm . '%'); + }); + } + + $logs = $query->paginate(20)->withQueryString(); + + return view('activity-logs.index', compact('logs')); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/DailyReportController.php b/app/Http/Controllers/DailyReportController.php index 766b941..b251ecc 100644 --- a/app/Http/Controllers/DailyReportController.php +++ b/app/Http/Controllers/DailyReportController.php @@ -31,7 +31,10 @@ class DailyReportController extends Controller ->where('report_date', $request->report_date) ->delete(); - $managementEmails = ['samuel@click-mobile.com']; + $managementEmails = ['kwesi@click-mobile.com']; + // $managementEmails = User::whereIn('group', ['directors']) + // ->pluck('email') + // ->toArray(); Mail::to($managementEmails)->send(new DailyReportSubmitted($report, auth()->user())); return response()->json([ diff --git a/app/Http/Controllers/LeaveRequestController.php b/app/Http/Controllers/LeaveRequestController.php index 05c31e0..db1f7cb 100644 --- a/app/Http/Controllers/LeaveRequestController.php +++ b/app/Http/Controllers/LeaveRequestController.php @@ -135,7 +135,7 @@ class LeaveRequestController extends Controller $leaveRequest->update([ 'status' => $validated['status'], 'admin_remarks' => $validated['admin_remarks'], - 'approved_by' => auth()->id(), // Track who approved it + 'approved_by' => auth()->id(), ]); return response()->json(['success' => true, 'message' => 'Leave status updated successfully!']); diff --git a/app/Http/Controllers/ManagementDashboardController.php b/app/Http/Controllers/ManagementDashboardController.php new file mode 100644 index 0000000..02688d5 --- /dev/null +++ b/app/Http/Controllers/ManagementDashboardController.php @@ -0,0 +1,64 @@ +where('status', 'Pending') + ->orderBy('created_at', 'asc') + ->get(); + + + $startDate = $request->input('start_date', Carbon::now()->startOfMonth()->format('Y-m-d')); + $endDate = $request->input('end_date', Carbon::now()->endOfMonth()->format('Y-m-d')); + + $staffReports = StaffMember::withCount(['dailyReports' => function ($query) use ($startDate, $endDate) { + $query->whereBetween('report_date', [$startDate, $endDate]); + }]) + ->orderBy('name') + ->get(); + + return view('management.index', compact('pendingLeaves', 'staffReports', 'startDate', 'endDate')); + } + + public function updateLeaveStatus(Request $request, $id) + { + $request->validate(['status' => 'required|in:Approved,Rejected']); + + $leave = LeaveRequest::findOrFail($id); + if ($request['status'] === 'Approved' && $leave->status !== 'Approved') { + if ($leave->leave_type === 'Annual') { + $staff = $leave->staff; + $staff->annual_leave_balance -= $leave->total_days; + $staff->save(); + } + } + + if ($request['status'] === 'Rejected' && $leave->status === 'Rejected') { + if ($leave->leave_type === 'Annual') { + $staff = $leave->staff; + $staff->annual_leave_balance += $leave->total_days; + $staff->save(); + } + } + + $leave->update([ + 'status' => strtoupper($request['status']), + 'admin_remarks' => 'N/A', + 'approved_by' => auth()->id(), + ]); + + // $leave->update(['status' => $request->status]); + + return response()->json(['success' => true, 'message' => "Leave request {$request->status} successfully."]); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/NetworkOperatorsController.php b/app/Http/Controllers/NetworkOperatorsController.php index 283bef4..b675bb1 100644 --- a/app/Http/Controllers/NetworkOperatorsController.php +++ b/app/Http/Controllers/NetworkOperatorsController.php @@ -79,6 +79,8 @@ class NetworkOperatorsController extends Controller $request->validate([ 'name' => 'required|string|max:200', 'country' => 'required|string', + 'contract_auto_renew' => 'required|boolean', + 'next_renewal_date' => 'nullable|required_if:contract_auto_renew,0|date', ]); $operator->update([ @@ -98,6 +100,10 @@ class NetworkOperatorsController extends Controller 'rate_type' => $request->rate_type, 'payment_terms' => $request->payment_terms, 'connection_type' => $request->connection_type, + 'contract_type' => $request->contract_type, + 'contract_validity' => $request->contract_validity, + 'contract_auto_renew' => $request->contract_auto_renew, + 'next_renewal_date' => $request->contract_auto_renew ? null : $request->next_renewal_date, ]); return response()->json(['message' => 'MNO Partner updated successfully!']); @@ -112,7 +118,8 @@ class NetworkOperatorsController extends Controller public function show($id) { $operator = NetworkOperator::findOrFail($id); - return view('network_operators.show', compact('operator')); + $staffMembers = \App\Models\StaffMember::orderBy('name')->get(); + return view('network_operators.show', compact('operator', 'staffMembers')); } public function storeConnection(Request $request, $operatorId) @@ -171,5 +178,45 @@ class NetworkOperatorsController extends Controller return redirect()->back()->with('success', 'Connection detail removed.'); } + public function storeDocument(Request $request, $operatorId) + { + $request->validate([ + 'document_names' => 'required|array', + 'document_names.*' => 'required|string|max:255', + 'documents' => 'required|array', + 'documents.*' => 'required|file|mimes:pdf,doc,docx,xlsx,xls,png,jpg,jpeg|max:10240', // 10MB limit + ]); + + foreach ($request->document_names as $index => $name) { + if ($request->hasFile("documents.$index")) { + $filePath = $request->file("documents.$index")->store('mno_documents', 'public'); + + \App\Models\NetworkOperatorDocument::create([ + 'network_operator_id' => $operatorId, + 'name' => $name, + 'file_path' => $filePath, + ]); + } + } + + return response()->json([ + 'success' => true, + 'message' => 'Documents uploaded successfully!' + ]); + } + + public function destroyDocument($id) + { + $document = \App\Models\NetworkOperatorDocument::findOrFail($id); + + // Optionally delete the file from storage + if (\Illuminate\Support\Facades\Storage::disk('public')->exists($document->file_path)) { + \Illuminate\Support\Facades\Storage::disk('public')->delete($document->file_path); + } + + $document->delete(); + + return redirect()->back()->with('success', 'Document deleted successfully.'); + } } \ No newline at end of file diff --git a/app/Http/Controllers/ServerController.php b/app/Http/Controllers/ServerController.php new file mode 100644 index 0000000..10219a2 --- /dev/null +++ b/app/Http/Controllers/ServerController.php @@ -0,0 +1,63 @@ +paginate(15); + return view('servers.index', compact('servers')); + } + + public function store(Request $request) + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'ip_address' => 'required|ip', + 'os_version' => 'nullable|string|max:255', + 'username' => 'required|string|max:255', + 'password' => 'required|string', + 'services' => 'nullable|array', + 'status' => 'required|in:Active,Inactive,Maintenance', + ]); + + Server::create($validated); + + return response()->json(['success' => true, 'message' => 'Server added successfully.']); + } + + // Return server data for the edit modal + public function show($id) + { + $server = Server::findOrFail($id); + return response()->json($server); + } + + public function update(Request $request, $id) + { + $server = Server::findOrFail($id); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'ip_address' => 'required|ip', + 'os_version' => 'nullable|string|max:255', + 'username' => 'required|string|max:255', + 'password' => 'nullable|string', + 'services' => 'nullable|array', + 'status' => 'required|in:Active,Inactive,Maintenance', + ]); + + // If password field is empty, remove it from the array so we don't overwrite the existing one + if (empty($validated['password'])) { + unset($validated['password']); + } + + $server->update($validated); + + return response()->json(['success' => true, 'message' => 'Server updated successfully.']); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/ServerCredentialController.php b/app/Http/Controllers/ServerCredentialController.php new file mode 100644 index 0000000..f249859 --- /dev/null +++ b/app/Http/Controllers/ServerCredentialController.php @@ -0,0 +1,70 @@ +user(); + + // Generate a 6-digit OTP + $otp = random_int(100000, 999999); + + // Store in cache for 5 minutes tied specifically to this user and server + $cacheKey = "otp_{$user->id}_server_{$server->id}"; + Cache::put($cacheKey, $otp, now()->addMinutes(5)); + + // Dispatch Email (You can replace this with a proper Mailable later) + Mail::raw("Your OTP to view the credentials for {$server->name} ({$server->ip_address}) is: {$otp}. This code expires in 5 minutes.", function ($message) use ($user) { + $message->to($user->email) + ->subject('Server Access Verification Code'); + }); + + return response()->json([ + 'success' => true, + 'message' => 'An OTP has been sent to your email.' + ]); + } + + // Step 2: Verify OTP and Release Password + public function verifyOtp(Request $request, $serverId) + { + $request->validate(['otp' => 'required|numeric']); + + $user = auth()->user(); + $server = Server::findOrFail($serverId); + $cacheKey = "otp_{$user->id}_server_{$server->id}"; + + if (Cache::get($cacheKey) == $request->otp) { + // Clear the OTP so it cannot be reused + Cache::forget($cacheKey); + + // Log the access in the audit trail + DB::table('password_access_audits')->insert([ + 'user_id' => $user->id, + 'server_id' => $server->id, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'accessed_at' => now(), + ]); + + // Return the decrypted password (Laravel handles decryption automatically via the model cast) + return response()->json([ + 'success' => true, + 'username' => $server->username, + 'password' => $server->password + ]); + } + + return response()->json(['success' => false, 'message' => 'Invalid or expired OTP.'], 422); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/VpnConfigController.php b/app/Http/Controllers/VpnConfigController.php new file mode 100644 index 0000000..9234288 --- /dev/null +++ b/app/Http/Controllers/VpnConfigController.php @@ -0,0 +1,166 @@ +paginate(15); + return view('vpns.index', compact('vpns')); + } + + // Handle Add Form Submission + public function store(Request $request) + { + $validated = $request->validate([ + 'partner_name' => 'required|string|max:255', + 'status' => 'required|in:Active,Inactive', + 'local_ip' => 'required|ip', + 'peer_ip' => 'required|ip', + 'encryption_domain_local' => 'nullable|string|max:255', + 'encryption_domain_remote' => 'nullable|string|max:255', + 'preshared_key' => 'required|string', + 'local_device' => 'nullable|string|max:255', + 'peer_device' => 'nullable|string|max:255', + 'ike_version' => 'required|integer', + 'phase1_encryption' => 'required|string|max:50', + 'phase1_hash' => 'required|string|max:50', + 'phase1_dh_group' => 'required|string|max:50', + 'phase1_lifetime' => 'required|integer', + 'phase2_encryption' => 'required|string|max:50', + 'phase2_hash' => 'required|string|max:50', + 'phase2_dh_group' => 'nullable|string|max:50', + 'phase2_lifetime' => 'required|integer', + 'pfs_enabled' => 'boolean', + ]); + + VpnConfig::create($validated); + + return response()->json(['success' => true, 'message' => 'VPN configuration added successfully.']); + } + + // Fetch data for the Edit Modal + public function show($id) + { + $vpn = VpnConfig::findOrFail($id); + + // Hide the pre-shared key from being sent back in the JSON response for security + $vpn->makeHidden('preshared_key'); + + return response()->json($vpn); + } + + // Handle Edit Form Submission + public function update(Request $request, $id) + { + $vpn = VpnConfig::findOrFail($id); + + $validated = $request->validate([ + 'partner_name' => 'required|string|max:255', + 'status' => 'required|in:Active,Inactive', + 'local_ip' => 'required|ip', + 'peer_ip' => 'required|ip', + 'encryption_domain_local' => 'nullable|string|max:255', + 'encryption_domain_remote' => 'nullable|string|max:255', + 'preshared_key' => 'nullable|string', + 'local_device' => 'nullable|string|max:255', + 'peer_device' => 'nullable|string|max:255', + 'ike_version' => 'required|integer', + 'phase1_encryption' => 'required|string|max:50', + 'phase1_hash' => 'required|string|max:50', + 'phase1_dh_group' => 'required|string|max:50', + 'phase1_lifetime' => 'required|integer', + 'phase2_encryption' => 'required|string|max:50', + 'phase2_hash' => 'required|string|max:50', + 'phase2_dh_group' => 'nullable|string|max:50', + 'phase2_lifetime' => 'required|integer', + 'pfs_enabled' => 'boolean', + ]); + + + // If the PSK field is empty, remove it so we don't overwrite the existing key + if (empty($validated['preshared_key'])) { + unset($validated['preshared_key']); + } + + $vpn->update($validated); + + return response()->json(['success' => true, 'message' => 'VPN configuration updated successfully.']); + } + + // Export the company's global standard VPN parameters + public function downloadStandardTemplate() + { + $template = "=================================================\n"; + $template .= " Click Mobile VPN Configuration Parameters\n"; + $template .= "=================================================\n\n"; + + $template .= "! Phase 1 (IKE / ISAKMP)\n"; + $template .= " Encryption Algorithm: AES-256\n"; + $template .= " Hash Algorithm: SHA-256\n"; + $template .= " Authentication Method: Pre-Shared Key (PSK)\n"; + $template .= " Diffie-Hellman Group: Group 14\n"; + $template .= " Lifetime: 86400 seconds\n\n"; + + $template .= "! Phase 2 (IPsec)\n"; + $template .= " Protocol: ESP\n"; + $template .= " Encryption Algorithm: AES-256\n"; + $template .= " Hash Algorithm: SHA-256\n"; + $template .= " Perfect Forward Secrecy (PFS): Group 14\n"; + $template .= " Lifetime: 3600 seconds\n\n"; + + $template .= "! Gateway Details\n"; + $template .= " Our Public Gateway IP: [216.55.137.19]\n"; + $template .= " Our Encryption Domain: [10.199.0.0/24]\n"; + + return response($template) + ->header('Content-Type', 'text/plain') + ->header('Content-Disposition', 'attachment; filename="standard_vpn_parameters.txt"'); + } + + // Fetch data to populate the Partner Export Modal + public function generateTemplate($id) + { + $vpn = VpnConfig::findOrFail($id); + + $template = "=================================================\n"; + $template .= " VPN Configuration for Partner: {$vpn->partner_name}\n"; + $template .= "=================================================\n\n"; + $template .= "! Phase 1 (IKE)\n"; + $template .= "crypto isakmp policy 10\n"; + $template .= " encr aes 256\n"; + $template .= " hash sha256\n"; + $template .= " authentication pre-share\n"; + $template .= " group 14\n"; + $template .= "crypto isakmp key {$vpn->preshared_key} address {$vpn->local_ip}\n\n"; + $template .= "! Phase 2 (IPsec)\n"; + $template .= "crypto ipsec transform-set TS-AES256-SHA256 esp-aes 256 esp-sha256-hmac\n"; + $template .= "crypto map VPNMAP 10 ipsec-isakmp\n"; + $template .= " set peer {$vpn->local_ip}\n"; + $template .= " set transform-set TS-AES256-SHA256\n"; + $template .= " match address 100\n\n"; + $template .= "! Access Control List (Encryption Domains)\n"; + $template .= "access-list 100 permit ip {$vpn->encryption_domain_remote} {$vpn->encryption_domain_local}\n"; + + return response()->json(['template' => $template, 'partner_name' => $vpn->partner_name]); + } + + // Process the edited text and force a file download + public function download(Request $request) + { + $request->validate([ + 'config_content' => 'required|string', + 'partner_name' => 'required|string', + ]); + + $filename = str_replace(' ', '_', strtolower($request->partner_name)) . '_vpn_config.txt'; + + return response($request->config_content) + ->header('Content-Type', 'text/plain') + ->header('Content-Disposition', "attachment; filename=\"{$filename}\""); + } +} \ No newline at end of file diff --git a/app/Models/ActivityLog.php b/app/Models/ActivityLog.php new file mode 100644 index 0000000..9c3ff59 --- /dev/null +++ b/app/Models/ActivityLog.php @@ -0,0 +1,30 @@ + 'array', + 'new_data' => 'array', + ]; + + // Links back to the User who made the change + public function user() + { + return $this->belongsTo(StaffMember::class); + } + + // Links back to the affected Model (Client, Server, VpnConfig, etc.) + public function loggable() + { + return $this->morphTo(); + } +} \ No newline at end of file diff --git a/app/Models/Client.php b/app/Models/Client.php index 290a2f8..76a3d5c 100644 --- a/app/Models/Client.php +++ b/app/Models/Client.php @@ -3,9 +3,10 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; - +use App\Traits\LogsActivity; class Client extends Model { + use LogsActivity; protected $guarded = [ 'id' ]; diff --git a/app/Models/ClientFile.php b/app/Models/ClientFile.php index bda725b..6f3bbdc 100644 --- a/app/Models/ClientFile.php +++ b/app/Models/ClientFile.php @@ -3,9 +3,11 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use App\Traits\LogsActivity; class ClientFile extends Model { + use LogsActivity; protected $guarded = array('id'); public function client_info(){ diff --git a/app/Models/ClientNote.php b/app/Models/ClientNote.php index 258a744..2542eaa 100644 --- a/app/Models/ClientNote.php +++ b/app/Models/ClientNote.php @@ -5,9 +5,11 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Spatie\Activitylog\Traits\LogsActivity; use Spatie\Activitylog\LogOptions; +use App\Traits\LogsActivity; class ClientNote extends Model { + use LogsActivity; protected $guarded = array('id'); protected static $logUnguarded = true; diff --git a/app/Models/DailyReport.php b/app/Models/DailyReport.php index ae273c6..87a6a42 100644 --- a/app/Models/DailyReport.php +++ b/app/Models/DailyReport.php @@ -3,9 +3,11 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use App\Traits\LogsActivity; class DailyReport extends Model { + use LogsActivity; protected $fillable = ['user_id', 'report_date', 'content']; protected $casts = [ 'report_date' => 'date', diff --git a/app/Models/LeaveManagement.php b/app/Models/LeaveManagement.php index 7d77ddb..380d6e8 100644 --- a/app/Models/LeaveManagement.php +++ b/app/Models/LeaveManagement.php @@ -3,9 +3,11 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use App\Traits\LogsActivity; class LeaveManagement extends Model { + use LogsActivity; protected $guarded = array('id'); public $table = "leave_management"; diff --git a/app/Models/LeaveRequest.php b/app/Models/LeaveRequest.php index 1da7930..2c26d81 100644 --- a/app/Models/LeaveRequest.php +++ b/app/Models/LeaveRequest.php @@ -15,10 +15,4 @@ class LeaveRequest extends Model { return $this->belongsTo(StaffMember::class, 'staff_member_id'); } - - // In StaffMember.php - public function leaveRequests() - { - return $this->hasMany(LeaveRequest::class, 'staff_member_id'); - } } diff --git a/app/Models/NetworkOperator.php b/app/Models/NetworkOperator.php index 937f9df..29eeb87 100755 --- a/app/Models/NetworkOperator.php +++ b/app/Models/NetworkOperator.php @@ -4,9 +4,11 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Casts\Attribute; +use App\Traits\LogsActivity; class NetworkOperator extends Model { + use LogsActivity; protected $guarded = array('id'); public $table = "network_operators"; @@ -15,6 +17,8 @@ class NetworkOperator extends Model 'support_emails' => 'array', 'finance_emails' => 'array', 'bind_specifics' => 'array', + 'contract_auto_renew' => 'boolean', + 'next_renewal_date' => 'date', ]; public function account_manager_info(){ return $this->hasOne('App\Models\StaffMember', 'id', 'account_manager_id'); @@ -23,6 +27,10 @@ class NetworkOperator extends Model { return $this->hasMany(NetworkOperatorConnection::class, 'network_operator_id'); } + public function documents() + { + return $this->hasMany(NetworkOperatorDocument::class); + } /* public function setBuyingRateAttribute($value) { diff --git a/app/Models/NetworkOperatorDocument.php b/app/Models/NetworkOperatorDocument.php new file mode 100644 index 0000000..804debf --- /dev/null +++ b/app/Models/NetworkOperatorDocument.php @@ -0,0 +1,17 @@ +belongsTo(NetworkOperator::class); + } +} \ No newline at end of file diff --git a/app/Models/OfficeLocation.php b/app/Models/OfficeLocation.php index 5145a10..390315a 100644 --- a/app/Models/OfficeLocation.php +++ b/app/Models/OfficeLocation.php @@ -3,9 +3,11 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use App\Traits\LogsActivity; class OfficeLocation extends Model { + use LogsActivity; protected $guarded = array('id'); protected $table = 'office_locations'; diff --git a/app/Models/OfficeLocationDocument.php b/app/Models/OfficeLocationDocument.php index 465d72c..9b681b7 100644 --- a/app/Models/OfficeLocationDocument.php +++ b/app/Models/OfficeLocationDocument.php @@ -3,9 +3,11 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use App\Traits\LogsActivity; class OfficeLocationDocument extends Model { + use LogsActivity; protected $table = 'office_location_documents'; protected $guarded = []; diff --git a/app/Models/SenderId.php b/app/Models/SenderId.php index 54cdbaa..1e25358 100644 --- a/app/Models/SenderId.php +++ b/app/Models/SenderId.php @@ -3,13 +3,14 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; -use Spatie\Activitylog\Traits\LogsActivity; -use Spatie\Activitylog\LogOptions; +// use Spatie\Activitylog\Traits\LogsActivity; +// use Spatie\Activitylog\LogOptions; +use App\Traits\LogsActivity; class SenderId extends Model{ // use LogsActivity; - + use LogsActivity; protected $guarded = array('id'); public $table = "sender_ids"; protected static $logName = 'senderid_log'; diff --git a/app/Models/Senderidold.php b/app/Models/Senderidold.php index 02e78a5..0c5437b 100644 --- a/app/Models/Senderidold.php +++ b/app/Models/Senderidold.php @@ -3,10 +3,11 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use App\Traits\LogsActivity; class Senderidold extends Model{ - + use LogsActivity; protected $guarded = array('id'); public $table = "sender_ids_copy"; diff --git a/app/Models/Server.php b/app/Models/Server.php new file mode 100644 index 0000000..085561d --- /dev/null +++ b/app/Models/Server.php @@ -0,0 +1,22 @@ + 'encrypted', // Automatically encrypts/decrypts + 'services' => 'array', // Casts JSON to PHP array + ]; + + // Hide password from standard array/JSON serialization for safety + protected $hidden = ['password']; +} \ No newline at end of file diff --git a/app/Models/ShortCode.php b/app/Models/ShortCode.php index 9509fdf..658a608 100644 --- a/app/Models/ShortCode.php +++ b/app/Models/ShortCode.php @@ -3,9 +3,12 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use App\Traits\LogsActivity; + class ShortCode extends Model { + use LogsActivity; protected $guarded = array('id'); public $table = "client_short_codes"; diff --git a/app/Models/StaffDependent.php b/app/Models/StaffDependent.php index 8dbaf11..20e88a3 100644 --- a/app/Models/StaffDependent.php +++ b/app/Models/StaffDependent.php @@ -3,9 +3,11 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use App\Traits\LogsActivity; class StaffDependent extends Model { + use LogsActivity; protected $table = 'staff_dependents'; protected $guarded = []; diff --git a/app/Models/StaffMember.php b/app/Models/StaffMember.php index 083f0b0..cbc07a8 100644 --- a/app/Models/StaffMember.php +++ b/app/Models/StaffMember.php @@ -5,10 +5,11 @@ namespace App\Models; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Database\Eloquent\Factories\HasFactory; +use App\Traits\LogsActivity; class StaffMember extends Authenticatable { - use HasFactory, Notifiable; + use HasFactory, Notifiable, LogsActivity; // 1. Explicitly define the custom table name protected $table = 'staff_members'; @@ -34,6 +35,7 @@ class StaffMember extends Authenticatable public function department(){ return $this->hasOne('App\Models\Department', 'id', 'department_id'); } + // 4. Cast dates correctly protected $casts = [ // 'dob' => 'date', diff --git a/app/Models/VpnConfig.php b/app/Models/VpnConfig.php index 89d0189..9b3ad27 100644 --- a/app/Models/VpnConfig.php +++ b/app/Models/VpnConfig.php @@ -3,10 +3,23 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use App\Traits\LogsActivity; class VpnConfig extends Model { - protected $guarded = ['id']; + //protected $guarded = ['id']; + use LogsActivity; + protected $fillable = [ + 'partner_name', 'peer_ip', 'local_ip', + 'local_device', 'peer_device', + 'encryption_domain_local', 'encryption_domain_remote', + 'preshared_key', 'status', + 'ike_version', 'phase1_encryption', 'phase1_hash', 'phase1_dh_group', 'phase1_lifetime', + 'phase2_encryption', 'phase2_hash', 'phase2_dh_group', 'phase2_lifetime', 'pfs_enabled' + ]; + protected $casts = [ + 'preshared_key' => 'encrypted', + ]; public function client_info(){ return $this->hasOne('App\Models\Client', 'id', 'client_id'); @@ -15,6 +28,6 @@ class VpnConfig extends Model return $this->hasOne('App\Models\NetworkOps', 'id', 'mno_id'); } public function userInfo(){ - return $this->hasOne('App\Models\SystemUser', 'id', 'user_id'); + return $this->hasOne('App\Models\StaffMember', 'id', 'user_id'); } } diff --git a/app/Traits/LogsActivity.php b/app/Traits/LogsActivity.php new file mode 100644 index 0000000..9d95a5a --- /dev/null +++ b/app/Traits/LogsActivity.php @@ -0,0 +1,65 @@ +recordActivity('created', null, $model->getAttributes()); + }); + + static::updated(function ($model) { + // Get only the fields that actually changed + $newAttributes = $model->getChanges(); + + // Ignore the 'updated_at' timestamp noise + unset($newAttributes['updated_at']); + + if (!empty($newAttributes)) { + $oldAttributes = array_intersect_key($model->getOriginal(), $newAttributes); + $model->recordActivity('updated', $oldAttributes, $newAttributes); + } + }); + + static::deleted(function ($model) { + $model->recordActivity('deleted', $model->getAttributes(), null); + }); + } + + protected function recordActivity($action, $oldData = null, $newData = null) + { + // Define sensitive fields to NEVER log + $hiddenFields = ['password', 'preshared_key', 'remember_token']; + + // Allow models to define custom fields to ignore via a protected $ignoreLogs array + if (property_exists($this, 'ignoreLogs')) { + $hiddenFields = array_merge($hiddenFields, $this->ignoreLogs); + } + + $cleanOld = $oldData ? collect($oldData)->except($hiddenFields)->toArray() : null; + $cleanNew = $newData ? collect($newData)->except($hiddenFields)->toArray() : null; + + ActivityLog::create([ + 'user_id' => Auth::id(), + 'loggable_type' => get_class($this), + 'loggable_id' => $this->id, + 'action' => $action, + 'old_data' => empty($cleanOld) ? null : $cleanOld, + 'new_data' => empty($cleanNew) ? null : $cleanNew, + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent() + ]); + } + + // Helper to fetch logs for a specific model instance + public function activityLogs() + { + return $this->morphMany(ActivityLog::class, 'loggable')->latest(); + } +} + +?> \ No newline at end of file diff --git a/database/migrations/2026_09_15_181659_create_servers_table.php b/database/migrations/2026_09_15_181659_create_servers_table.php new file mode 100644 index 0000000..9a862cc --- /dev/null +++ b/database/migrations/2026_09_15_181659_create_servers_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('name'); // e.g., "Main PBX Engine" or "Synology NAS" + $table->string('ip_address'); + $table->string('os_version')->nullable(); // e.g., "AlmaLinux 9", "Debian" + $table->string('username')->default('root'); + $table->text('password'); // Will be encrypted via Model casting + $table->json('services')->nullable(); // Array of services like ['Docker', 'PostGIS', 'Dovecot'] + $table->string('status')->default('Active'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('servers'); + } +}; diff --git a/database/migrations/2026_09_15_181700_create_password_access_audits_table.php b/database/migrations/2026_09_15_181700_create_password_access_audits_table.php new file mode 100644 index 0000000..9608bf1 --- /dev/null +++ b/database/migrations/2026_09_15_181700_create_password_access_audits_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('server_id')->constrained()->cascadeOnDelete(); + $table->string('ip_address'); + $table->string('user_agent')->nullable(); + $table->timestamp('accessed_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('password_access_audits'); + } +}; diff --git a/database/migrations/2026_09_15_201604_create_vpn_configs_table.php b/database/migrations/2026_09_15_201604_create_vpn_configs_table.php new file mode 100644 index 0000000..e855d55 --- /dev/null +++ b/database/migrations/2026_09_15_201604_create_vpn_configs_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('partner_name'); // e.g., "MTN Ghana" or "Telecel" + $table->string('peer_ip'); // Partner's Gateway + $table->string('local_ip'); // Your Gateway + $table->string('encryption_domain_local')->nullable(); // Your internal subnets/VLANs + $table->string('encryption_domain_remote')->nullable(); // Partner's subnets + $table->text('preshared_key'); // Encrypted + $table->string('status')->default('Active'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('vpn_configs'); + } +}; diff --git a/database/migrations/2026_09_15_220259_add_crypto_params_to_vpn_configs_table.php b/database/migrations/2026_09_15_220259_add_crypto_params_to_vpn_configs_table.php new file mode 100644 index 0000000..37e418b --- /dev/null +++ b/database/migrations/2026_09_15_220259_add_crypto_params_to_vpn_configs_table.php @@ -0,0 +1,42 @@ +string('local_device')->nullable()->after('peer_ip'); + $table->string('peer_device')->nullable()->after('local_device'); + + // Phase 1 (IKE) + $table->integer('ike_version')->default(2)->after('preshared_key'); + $table->string('phase1_encryption')->default('AES-256')->after('ike_version'); + $table->string('phase1_hash')->default('SHA-256')->after('phase1_encryption'); + $table->string('phase1_dh_group')->default('14')->after('phase1_hash'); + $table->integer('phase1_lifetime')->default(28800)->after('phase1_dh_group'); + + // Phase 2 (IPSec) + $table->string('phase2_encryption')->default('AES-256')->after('phase1_lifetime'); + $table->string('phase2_hash')->default('SHA-256')->after('phase2_encryption'); + $table->string('phase2_dh_group')->nullable()->after('phase2_hash'); + $table->integer('phase2_lifetime')->default(3600)->after('phase2_dh_group'); + $table->boolean('pfs_enabled')->default(true)->after('phase2_lifetime'); + }); + } + + public function down() + { + Schema::table('vpn_configs', function (Blueprint $table) { + $table->dropColumn([ + 'local_device', 'peer_device', 'ike_version', + 'phase1_encryption', 'phase1_hash', 'phase1_dh_group', 'phase1_lifetime', + 'phase2_encryption', 'phase2_hash', 'phase2_dh_group', 'phase2_lifetime', 'pfs_enabled' + ]); + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_09_18_155053_create_activity_logs_table.php b/database/migrations/2026_09_18_155053_create_activity_logs_table.php new file mode 100644 index 0000000..acef3cf --- /dev/null +++ b/database/migrations/2026_09_18_155053_create_activity_logs_table.php @@ -0,0 +1,27 @@ +id();$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + + // This creates `loggable_type` and `loggable_id` to link to ANY model + $table->morphs('loggable'); + + $table->string('action'); // 'created', 'updated', 'deleted'$table->json('old_data')->nullable(); // State before update + $table->json('new_data')->nullable(); // State after update$table->string('ip_address')->nullable(); + $table->string('user_agent')->nullable();$table->timestamps(); + }); + } + + public function down() + { + Schema::dropIfExists('activity_logs'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_09_18_173530_add_missing_columns_to_activity_logs_table.php b/database/migrations/2026_09_18_173530_add_missing_columns_to_activity_logs_table.php new file mode 100644 index 0000000..774165b --- /dev/null +++ b/database/migrations/2026_09_18_173530_add_missing_columns_to_activity_logs_table.php @@ -0,0 +1,23 @@ +json('old_data')->nullable()->after('action'); + $table->string('ip_address')->nullable()->after('new_data'); + }); + } + + public function down() + { + Schema::table('activity_logs', function (Blueprint $table) { + $table->dropColumn(['old_data', 'ip_address']); + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_09_18_181328_create_network_operator_documents_table.php b/database/migrations/2026_09_18_181328_create_network_operator_documents_table.php new file mode 100644 index 0000000..2a8d14c --- /dev/null +++ b/database/migrations/2026_09_18_181328_create_network_operator_documents_table.php @@ -0,0 +1,42 @@ +id(); + $table->foreignId('network_operator_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('file_path'); + $table->timestamps(); + }); + } + public function up() + { + Schema::create('network_operator_documents', function (Blueprint $table) { + $table->id(); + + // Explicitly define as standard unsigned integer to match older tables + $table->unsignedInteger('network_operator_id'); + + $table->string('name'); + $table->string('file_path'); + $table->timestamps(); + + // Explicitly define the constraint + $table->foreign('network_operator_id') + ->references('id') + ->on('network_operators') + ->onDelete('cascade'); + }); + } + + public function down() + { + Schema::dropIfExists('network_operator_documents'); + } +}; \ No newline at end of file diff --git a/features.md b/features.md new file mode 100644 index 0000000..ceeec0e --- /dev/null +++ b/features.md @@ -0,0 +1,33 @@ +Click ERP + + +Client Management (CRM) +• Client Profiles: Complete tracking of demographics, services, contract types, billing modes, and Account Manager assignments. + +• Entity Record entries: A dedicated view for each client housing sub-records for Short Codes (SMS/Voice/USSD), Payments, and Onboarding Progress. + +• Time-Locked Communications: A Notes system where staff can log client interactions. + +• Client Data Table: A client's view with real-time search, advanced filtering by service/status, and CSV/PDF exports. + +Staff Daily Reporting +• Report Submission Portal: An interface for staff to log their daily activities, achievements, and challenges. + +• Time-Window Enforcement: Strict backend validation that automatically locks report submissions 48 hours after the target date. + +• Automated Dispatch: Automatic generation and routing of customized HTML email notifications to management upon successful report submission. + +Global Offices & Holidays +• Branch office Management: Centralized directory for managing physical office locations and assigning Country Managers. + +• Public Holiday Management : The ability to enter recurring or localized public holidays + + +Management & Admin Dashboard +• Centralized Approvals: A dedicated hub for reviewing, approving, or rejecting staff Leave Requests. + +• Compliance Tracking: Automated calculations comparing actual submitted reports against expected working days to gauge staff consistency. + +• Report Overrides: A feature that allows management to temporarily unlock the 48-hour submission window for specific staff members and past dates. + + diff --git a/public/assets/js/mno-show.js b/public/assets/js/mno-show.js new file mode 100644 index 0000000..4e67236 --- /dev/null +++ b/public/assets/js/mno-show.js @@ -0,0 +1,274 @@ +$(document).ready(function() { + + // ========================================== + // 1. CONNECTION DETAILS LOGIC + // ========================================== + $('#connectionModeSelect').on('change', function() { + if ($(this).val() === 'VPN Tunnel') {$('#vpnFormContainer').slideDown(); + } else { + $('#vpnFormContainer').slideUp(); + $('#vpnFileInput').val(''); + } + }); + + $('#addConnectionForm').on('submit', function(e) { + e.preventDefault(); + + let $form =$(this); + let $submitBtn =$('#btnSubmitConnection'); + let originalText = $submitBtn.html(); + let $alertBox =$('#connectionModalAlert'); + let formData = new FormData(this); + + $submitBtn.html('Saving...').prop('disabled', true); + $alertBox.html(''); + $form.find('.is-invalid').removeClass('is-invalid');$form.find('.invalid-feedback').remove(); + + $.ajax({ + url: $form.attr('action'), + type: 'POST', + data: formData, + processData: false, + contentType: false, + success: function(response) { + if (response.success) { + $('#addConnectionModal').modal('hide'); + Swal.fire({ + icon: 'success', + title: 'Success!', + text: response.message, + confirmButtonColor: '#5c4df0' + }).then(() => location.reload()); + } + }, + error: function(xhr) { + if (xhr.status === 422) { + let errors = xhr.responseJSON.errors; + $alertBox.html(` + + `); + $.each(errors, function(key, value) { + let $input =$form.find('[name="' + key + '"]'); + if ($input.length) { + $input.addClass('is-invalid');$input.parent().append('
' + value[0] + '
'); + } + }); + } else { + $alertBox.html(` + + `); + } + }, + complete: function() { + $submitBtn.html(originalText).prop('disabled', false); + } + }); + }); + + $('#addConnectionModal').on('hidden.bs.modal', function () { + $('#addConnectionForm')[0].reset(); + $('#vpnFormContainer').hide(); + $('#connectionModalAlert').html(''); + $('#addConnectionForm').find('.is-invalid').removeClass('is-invalid'); + $('#addConnectionForm').find('.invalid-feedback').remove(); + }); + + // ========================================== + // 2. MULTIPLE DOCUMENT UPLOAD LOGIC + // ========================================== + $('#addDocRowBtn').on('click', function() { + let newRow = ` +
+
+ +
+
+ +
+
+ +
+
+ `; + $('#documentRowsContainer').append(newRow); + updateRemoveButtons(); + }); + + $(document).on('click', '.remove-doc-row', function() {$(this).closest('.document-row').remove(); + updateRemoveButtons(); + }); + + function updateRemoveButtons() { + if ($('.document-row').length > 1) {$('.remove-doc-row').prop('disabled', false); + } else { + $('.remove-doc-row').prop('disabled', true); + } + } + + $('#addDocumentForm').on('submit', function(e) { + e.preventDefault(); + + let $form =$(this); + let $submitBtn =$('#btnSubmitDocuments'); + let originalText = $submitBtn.html(); + let $alertBox =$('#documentModalAlert'); + let formData = new FormData(this); + + $submitBtn.html('Uploading...').prop('disabled', true); + $alertBox.html('');$form.find('.is-invalid').removeClass('is-invalid'); + + $.ajax({ + url: $form.attr('action'), + type: 'POST', + data: formData, + processData: false, + contentType: false, + success: function(response) { + if (response.success) { + $('#addDocumentModal').modal('hide'); + Swal.fire({ + icon: 'success', + title: 'Uploaded!', + text: response.message, + confirmButtonColor: '#5c4df0' + }).then(() => location.reload()); + } + }, + error: function(xhr) { + $alertBox.html(` + + `); + }, + complete: function() { + $submitBtn.html(originalText).prop('disabled', false); + } + }); + }); + + $('#addDocumentModal').on('hidden.bs.modal', function () { + $('#addDocumentForm')[0].reset(); + $('#documentModalAlert').html(''); + $('.document-row:not(:first)').remove(); + updateRemoveButtons(); + }); + + // ========================================== + // 3. EDIT MNO PARTNER LOGIC + // ========================================== + $('.btn-edit-operator').on('click', function() { + // Fetch data via the existing getJson endpoint + $.ajax({ + url: `${baseUrl}/mnos/json/${operatorId}`, // Adjust URL if your route is named differently + type: 'GET', + success: function(data) { + $('#edit_mno_id').val(data.id); + $('#edit_name').val(data.name); + $('#edit_country').val(data.country); + $('#edit_connection_status').val(data.connection_status); + $('#edit_connection_type').val(data.connection_type); + + $('#edit_mno_account_manager').val(data.mno_account_manager); + $('#edit_technical_support_person').val(data.technical_support_person); + $('#edit_contact_person').val(data.contact_person); + $('#edit_contact_person_phone').val(data.contact_person_phone); + $('#edit_contact_person_email').val(data.contact_person_email); + $('#edit_support_skype').val(data.support_skype); + + $('#edit_buying_rate').val(data.buying_rate); + $('#edit_rate_type').val(data.rate_type); + $('#edit_payment_terms').val(data.payment_terms); + + // Reset tabs to Basic Info + $('#editOperatorModal .nav-link').removeClass('active'); + $('#editOperatorModal .tab-pane').removeClass('show active'); + $('#editOperatorModal .nav-link').first().addClass('active'); + $('#edit-tab-basic').addClass('show active'); + + $('#edit_contract_type').val(data.contract_type); + $('#edit_contract_validity').val(data.contract_validity); + + // Handle Boolean for Auto Renew + let isAutoRenew = data.contract_auto_renew === false || data.contract_auto_renew === 0 ? '0' : '1'; + $('#edit_contract_auto_renew').val(isAutoRenew).trigger('change'); + + // Format date to YYYY-MM-DD for the HTML date input if it exists + if (data.next_renewal_date) { + let formattedDate = data.next_renewal_date.split('T')[0]; + $('#edit_next_renewal_date').val(formattedDate); + } else { + $('#edit_next_renewal_date').val(''); + } + + // Reset tabs to Basic Info + $('#editOperatorModal .nav-link').removeClass('active'); + $('#editOperatorModal .tab-pane').removeClass('show active'); + $('#editOperatorModal .nav-link').first().addClass('active'); + $('#edit-tab-basic').addClass('show active'); + + $('#editOperatorModal').modal('show'); + } + }); + }); + // Toggle the Renewal Date field based on Auto Renew selection + $('#edit_contract_auto_renew').on('change', function() { + if ($(this).val() === '0') {$('#renewal_date_container').slideDown(); + $('#edit_next_renewal_date').prop('required', true); + } else { + $('#renewal_date_container').slideUp(); + $('#edit_next_renewal_date').prop('required', false).val(''); + } + }); + $('#editOperatorForm').on('submit', function(e) { + e.preventDefault(); + let $form =$(this); + let $btn =$('#btnUpdateOperator'); + let originalText = $btn.html(); + + $btn.html('Updating...').prop('disabled', true); + $form.find('.is-invalid').removeClass('is-invalid');$form.find('.invalid-feedback').remove(); + + $.ajax({ + url: `${baseUrl}/mnos/${operatorId}`, // PUT endpoint + type: 'POST', // Laravel uses POST + @method('PUT') for forms + data: $form.serialize(), + success: function(response) { + $('#editOperatorModal').modal('hide'); + Swal.fire({ + icon: 'success', + title: 'Updated!', + text: response.message, + timer: 1500, + showConfirmButton: false + }).then(() => location.reload()); + }, + error: function(xhr) { + if (xhr.status === 422) { + $.each(xhr.responseJSON.errors, function(key, value) { + let $input =$form.find('[name="' + key + '"]'); + $input.addClass('is-invalid');$input.parent().append('
' + value[0] + '
'); + }); + + // Auto-switch to the tab with the error + let $firstError =$form.find('.is-invalid').first(); + if ($firstError.length) { + let tabId = $firstError.closest('.tab-pane').attr('id');$form.find('[data-bs-target="#' + tabId + '"]').tab('show'); + } + } + }, + complete: function() { + $btn.html(originalText).prop('disabled', false); + } + }); + }); +}); \ No newline at end of file diff --git a/public/assets/js/vpn-index.js b/public/assets/js/vpn-index.js new file mode 100644 index 0000000..c819980 --- /dev/null +++ b/public/assets/js/vpn-index.js @@ -0,0 +1,183 @@ +$(document).ready(function() { + + // 1. Add VPN Config + $('#addVpnForm').on('submit', function(e) { + e.preventDefault(); + let $form = $(this); + let $btn = $('#btnSaveVpn'); + let originalText = $btn.html(); + + $btn.html('Saving...').prop('disabled', true); + $form.find('.is-invalid').removeClass('is-invalid'); + + $.ajax({ + url: `${base_url}/vpns`, + type: 'POST', + data: $form.serialize(), + success: function(response) { + $('#addVpnModal').modal('hide'); + Swal.fire({ + icon: 'success', + title: 'Added!', + text: response.message, + timer: 1500, + showConfirmButton: false + }).then(() => location.reload()); + }, + error: function(xhr) { + if (xhr.status === 422) { + $.each(xhr.responseJSON.errors, function(key, value) { + let $input = $form.find('[name="' + key + '"]'); + $input.addClass('is-invalid'); + $input.parent().append('
' + value[0] + '
'); + }); + + let $firstError = $form.find('.is-invalid').first(); + if ($firstError.length) { + let tabId = $firstError.closest('.tab-pane').attr('id'); + $form.find('[data-bs-target="#' + tabId + '"]').tab('show'); + } + } else { + Swal.fire('Error', 'An unexpected error occurred.', 'error'); + } + }, + complete: function() { + $btn.html(originalText).prop('disabled', false); + } + }); + }); + + // 2. Fetch Data for Edit Modal + $(document).on('click', '.btn-edit-vpn', function(e) { + e.preventDefault(); + let vpnId = $(this).data('id'); + + $.ajax({ + url: `${base_url}/vpns/${vpnId}`, + type: 'GET', + success: function(vpn) { + // Basic Info Tab + $('#edit_vpn_id').val(vpn.id); + $('#edit_partner_name').val(vpn.partner_name); + $('#edit_status').val(vpn.status); + $('#edit_local_device').val(vpn.local_device); + $('#edit_peer_device').val(vpn.peer_device); + $('#edit_local_ip').val(vpn.local_ip); + $('#edit_peer_ip').val(vpn.peer_ip); + $('#edit_encryption_domain_local').val(vpn.encryption_domain_local); + $('#edit_encryption_domain_remote').val(vpn.encryption_domain_remote); + + // Phase 1 Tab + $('#edit_ike_version').val(vpn.ike_version); + $('#edit_phase1_encryption').val(vpn.phase1_encryption); + $('#edit_phase1_hash').val(vpn.phase1_hash); + $('#edit_phase1_dh_group').val(vpn.phase1_dh_group); + $('#edit_phase1_lifetime').val(vpn.phase1_lifetime); + + // Phase 2 Tab + $('#edit_phase2_encryption').val(vpn.phase2_encryption); + $('#edit_phase2_hash').val(vpn.phase2_hash); + $('#edit_phase2_dh_group').val(vpn.phase2_dh_group); + $('#edit_phase2_lifetime').val(vpn.phase2_lifetime); + + // Handle Checkbox for PFS + if (vpn.pfs_enabled == 1 || vpn.pfs_enabled === true) { + $('#edit_pfs_enabled').prop('checked', true); + } else { + $('#edit_pfs_enabled').prop('checked', false); + } + + // Reset tabs so it opens to the first tab by default + $('#editVpnModal .nav-link').removeClass('active'); + $('#editVpnModal .tab-pane').removeClass('show active'); + $('#editVpnModal .nav-link').first().addClass('active'); + $('#edit-tab-basic').addClass('show active'); + + $('#editVpnModal').modal('show'); + } + }); + }); + + // 3. Update VPN Config + $('#editVpnForm').on('submit', function(e) { + e.preventDefault(); + let $form = $(this); + let vpnId = $('#edit_vpn_id').val(); + let $btn = $('#btnUpdateVpn'); + let originalText = $btn.html(); + + $btn.html('Updating...').prop('disabled', true); + $form.find('.is-invalid').removeClass('is-invalid'); + $form.find('.invalid-feedback').remove(); + + $.ajax({ + url: `${base_url}/vpns/${vpnId}`, + type: 'POST', + data: $form.serialize(), + success: function(response) { + $('#editVpnModal').modal('hide'); + Swal.fire({ + icon: 'success', + title: 'Updated!', + text: response.message, + timer: 1500, + showConfirmButton: false + }).then(() => location.reload()); + }, + error: function(xhr) { + if (xhr.status === 422) { + $.each(xhr.responseJSON.errors, function(key, value) { + let $input = $form.find('[name="' + key + '"]'); + $input.addClass('is-invalid'); + $input.parent().append('
' + value[0] + '
'); + }); + + + let $firstError = $form.find('.is-invalid').first(); + if ($firstError.length) { + let tabId = $firstError.closest('.tab-pane').attr('id'); + $form.find('[data-bs-target="#' + tabId + '"]').tab('show'); + } + } else { + Swal.fire('Error', 'An unexpected error occurred.', 'error'); + } + }, + complete: function() { + $btn.html(originalText).prop('disabled', false); + } + }); + }); + + // 4. Export VPN Configuration (Trigger Modal) + $(document).on('click', '.btn-export-vpn', function(e) { + e.preventDefault(); + let vpnId = $(this).data('id'); + let $btn = $(this); + let originalHtml = $btn.html(); + + $btn.html('').prop('disabled', true); + + $.ajax({ + url: `${base_url}/vpns/${vpnId}/template`, + type: 'GET', + success: function(response) { + $('#export_partner_name').val(response.partner_name); + $('#export_config_content').val(response.template); + $('#exportVpnModal').modal('show'); + }, + error: function() { + Swal.fire('Error', 'Failed to generate configuration template.', 'error'); + }, + complete: function() { + $btn.html(originalHtml).prop('disabled', false); + } + }); + }); + + // Clean up modals on close + $('#addVpnModal, #editVpnModal, #exportVpnModal').on('hidden.bs.modal', function () { + $(this).find('form')[0].reset(); + $(this).find('.is-invalid').removeClass('is-invalid'); + $(this).find('.invalid-feedback').remove(); + }); +}); \ No newline at end of file diff --git a/resources/views/activity-logs/index.blade.php b/resources/views/activity-logs/index.blade.php new file mode 100644 index 0000000..de84648 --- /dev/null +++ b/resources/views/activity-logs/index.blade.php @@ -0,0 +1,203 @@ +@extends('layouts.masterbeta') + +@section('title', 'Click ERP - System Activity Logs') + +@section('breadcrumbs') + + + Activity Logs +@endsection + +@section('content') +
+
+

System Audit Trail

+

Monitor user actions, data modifications, and security events.

+
+
+ +
+ + +
+
+
+
+ + +
+
+
+ +
+
+ +
+
+ +
+
+ + +
+
+
+ + +
+ + + + + + + + + + + + @forelse($logs as $log) + + + + + + + + @empty + + + + @endforelse + +
TimestampUserActionEntity ContextDetails
+
{{ $log->created_at->format('d M Y') }}
+
{{ $log->created_at->format('h:i:s A') }}
+
+
{{ $log->user->name ?? 'System/Guest' }}
+
{{ $log->ip_address }}
+
+ @php + $badgeColor = match($log->action) { + 'created' => 'success', + 'updated' => 'primary', + 'deleted' => 'danger', + default => 'secondary' + }; + @endphp + + {{ $log->action }} + + +
+ {{ class_basename($log->loggable_type) }} #{{ $log->loggable_id }} +
+
+ @if(!empty($log->old_data) || !empty($log->new_data)) + + @else + No Context + @endif +
+ + No activity records found. +
+
+ + + @if($logs->hasPages()) + + @endif +
+@endsection + +@push('modals') + + +@endpush + +@push('scripts') + +@endpush \ No newline at end of file diff --git a/resources/views/dashboard/index.blade.php b/resources/views/dashboard/index.blade.php index d0b37bf..f9ce1ea 100644 --- a/resources/views/dashboard/index.blade.php +++ b/resources/views/dashboard/index.blade.php @@ -130,10 +130,10 @@
-
+
diff --git a/resources/views/layouts/masterbeta.blade.php b/resources/views/layouts/masterbeta.blade.php index 20e9228..5b05f04 100644 --- a/resources/views/layouts/masterbeta.blade.php +++ b/resources/views/layouts/masterbeta.blade.php @@ -38,6 +38,13 @@ .table-custom th { background-color: #f8f9fc; color: #5a617a; font-weight: 600; font-size: 0.8rem; text-transform: uppercase; border-bottom: 2px solid #eaedf1; padding: 1rem; } .table-custom td { padding: 1rem; vertical-align: middle; font-size: 0.9rem; border-bottom: 1px solid #eaedf1; } .form-control:focus, .form-select:focus { border-color: #5c4df0; box-shadow: 0 0 0 0.25rem rgba(92, 77, 240, 0.25); } + + .sidebar-nav-item .toggle-icon { + transition: transform 0.3s ease; + } + .sidebar-nav-item.collapsed .toggle-icon { + transform: rotate(-90deg); + } @stack('styles') diff --git a/resources/views/layouts/partials/sidebar.blade.php b/resources/views/layouts/partials/sidebar.blade.php index fe96771..8aaf8a6 100644 --- a/resources/views/layouts/partials/sidebar.blade.php +++ b/resources/views/layouts/partials/sidebar.blade.php @@ -5,7 +5,6 @@
Click ERP
-
@@ -16,6 +15,11 @@ Dashboard + @if(\Auth::user()->department->name == 'Directors') + + Management Hub + + @endif Clients @@ -36,16 +40,47 @@ Document Vault - - + @if(\Auth::user()->department->name == 'Technical') + + + + + Infrastructure + + + + + + @endif Reports + Office Locations Staff Directory + + + Activity Logs + \ No newline at end of file diff --git a/resources/views/leave/my_leave.blade.php b/resources/views/leave/my_leave.blade.php index ff9b6f0..ef25017 100644 --- a/resources/views/leave/my_leave.blade.php +++ b/resources/views/leave/my_leave.blade.php @@ -40,12 +40,14 @@
Leave Policy Quick Guide
    -
  • Policy 1
  • +
  • Pending Requests: Can be canceled before HR approval. Once approved, contact HR to amend.
  • Policy 2
  • Policy 3
  • -
  • Pending Requests: Can be canceled before HR approval. Once approved, contact HR to amend.
  • + -->
diff --git a/resources/views/management/index.blade.php b/resources/views/management/index.blade.php new file mode 100644 index 0000000..4f3a014 --- /dev/null +++ b/resources/views/management/index.blade.php @@ -0,0 +1,200 @@ +@extends('layouts.masterbeta') + +@section('title', 'Click ERP - Management Dashboard') + +@section('breadcrumbs') + + + Management Dashboard +@endsection + +@section('content') +
+
+

Management Hub

+

Centralized oversight for leave and reports

+
+
+ +
+
+ + +
+ +
+ + +
+
Pending Requests
+
+
+ + + + + + + + + + + @forelse($pendingLeaves as $leave) + + + + + + + + @empty + + + + @endforelse + +
Staff MemberLeave TypeDurationRequested OnActions
{{ $leave->user->name ?? 'N/A' }}{{ $leave->leave_type ?? 'Annual' }} + {{ \Carbon\Carbon::parse($leave->start_date)->format('d M') }} - {{ \Carbon\Carbon::parse($leave->end_date)->format('d M Y') }} +
{{ $leave->total_days }} days +
{{ $leave->created_at->format('d M Y') }} + + +
+ All caught up. No pending leave requests. +
+
+
+ + +
+
+
Report Submission Metrics
+
+ + + + + +
+
+ +
+ + + + + + + + + + + @foreach($staffReports as $staff) + @php + // Calculate working days assuming 5-day work week roughly (can be refined based on actual business logic) + $start = \Carbon\Carbon::parse($startDate); + $end = \Carbon\Carbon::parse($endDate); + $workDays = max($start->diffInDaysFiltered(function (Carbon\Carbon $date) { + return $date->isWeekday(); + }, $end), 1); // Avoid division by zero + + $completionPercentage = min(round(($staff->daily_reports_count / $workDays) * 100), 100); + $barColor = $completionPercentage >= 80 ? 'bg-success' : ($completionPercentage >= 50 ? 'bg-warning' : 'bg-danger'); + @endphp + + + + + + + @endforeach + +
Staff MemberDesignationReports SubmittedPerformance (Approx)
+ {{ $staff->name }} + {{ $staff->designation ?? 'Staff' }} + {{ $staff->daily_reports_count }} + / ~{{ $workDays }} expected + +
+ {{ $completionPercentage }}% +
+
+
+
+
+
+
+ +
+
+@endsection + +@push('scripts') + +@endpush \ No newline at end of file diff --git a/resources/views/network_operators/index.blade.php b/resources/views/network_operators/index.blade.php index da16153..a34122b 100644 --- a/resources/views/network_operators/index.blade.php +++ b/resources/views/network_operators/index.blade.php @@ -66,9 +66,10 @@
diff --git a/resources/views/network_operators/partials/modals.blade.php b/resources/views/network_operators/partials/modals.blade.php new file mode 100644 index 0000000..446af1d --- /dev/null +++ b/resources/views/network_operators/partials/modals.blade.php @@ -0,0 +1,258 @@ + + + + + + + + \ No newline at end of file diff --git a/resources/views/network_operators/show.blade.php b/resources/views/network_operators/show.blade.php index 14d91c0..07ae89e 100644 --- a/resources/views/network_operators/show.blade.php +++ b/resources/views/network_operators/show.blade.php @@ -21,13 +21,16 @@

{{ $operator->name }}

{{ $operator->country }} • - - {{ $operator->connection_status ?? 'Offline' }} + + {{ $operator->connection_status ?? 'Inactive' }}

+ Back to List @@ -66,6 +69,7 @@
+
Operating Country {{ $operator->country }} @@ -84,6 +88,36 @@
+ +
+
+ Contract Details +
+
+
+
+ Contract Type + {{ $operator->contract_type ?? 'N/A' }} +
+
+ Contract Validity + {{ $operator->contract_validity ?? 'N/A' }} +
+
+ Auto Renew + @if(is_null($operator->contract_auto_renew)) + N/A + @elseif($operator->contract_auto_renew) + Yes + @else + No +
+ Renewal Date: {{ $operator->next_renewal_date ? $operator->next_renewal_date->format('d M Y') : 'Not Set' }} +
+ @endif +
+
+
@@ -220,182 +254,70 @@
+ +
+
+
+ Operator Documents +
+ +
+
+ +
+ + + + + + + + + + + @forelse($operator->documents as $doc) + + + + + + + @empty + + + + @endforelse + +
Document NameFile TypeUploaded OnActions
{{ $doc->name }}{{ strtoupper(pathinfo($doc->file_path, PATHINFO_EXTENSION)) }}{{ $doc->created_at->format('d M Y') }} + + View + +
+ @csrf + @method('DELETE') + +
+
No documents uploaded yet.
+
+
@endsection @push('modals') - - - - + @include('network_operators.partials.modals') @endpush @push('scripts') + + + @endpush \ No newline at end of file diff --git a/resources/views/servers/index.blade.php b/resources/views/servers/index.blade.php new file mode 100644 index 0000000..5848815 --- /dev/null +++ b/resources/views/servers/index.blade.php @@ -0,0 +1,496 @@ +@extends('layouts.masterbeta') + +@section('title', 'Click ERP - Server Infrastructure') + +@section('breadcrumbs') + + + Servers +@endsection + +@section('content') +
+
+

Infrastructure Management

+

Server list

+
+ +
+ +
+ +
+
+
+
+ + +
+
+
+
+ +
+ + + + + + + + + + + + @forelse($servers as $server) + + + + + + + + @empty + + + + @endforelse + +
Server / IPOS VersionServicesStatusActions
+
{{ $server->name }}
+
{{ $server->ip_address }}
+
{{ $server->os_version ?? 'Unknown' }} + @if(is_array($server->services)) + @foreach($server->services as $service) + {{ $service }} + @endforeach + @else + None + @endif + + + {{ $server->status }} + + + + + + +
+ + No servers added yet. +
+
+
+@endsection + +@push('modals') + + + + + + + +@endpush + +@push('scripts') + +@endpush \ No newline at end of file diff --git a/resources/views/shortcodes/index.blade.php b/resources/views/shortcodes/index.blade.php index e870b28..3d286ef 100644 --- a/resources/views/shortcodes/index.blade.php +++ b/resources/views/shortcodes/index.blade.php @@ -33,7 +33,7 @@

Short Codes Management

-

Manage SMS, USSD, and Voice short codes and validity periods.

+

Manage SMS, USSD, and Voice short codes and their expiry dates.

+
+ + +
+ +
+
+
+
+ + +
+
+
+
+ +
+ + + + + + + + + + + + @forelse($vpns as $vpn) + + + + + + + + @empty + + + + @endforelse + +
PartnerGateways (Local ➔ Peer)Encryption Domains (Local ➔ Remote)StatusActions
{{ $vpn->partner_name }} +
+ {{ $vpn->local_ip }} + + {{ $vpn->peer_ip }} +
+
+
+ {{ $vpn->encryption_domain_local ?? 'Any' }} + + {{ $vpn->encryption_domain_remote ?? 'Any' }} +
+
+ + {{ $vpn->status }} + + + + +
+ + No VPN configurations added yet. +
+
+ + @if($vpns->hasPages()) + + @endif +
+@endsection + +@push('modals') + + + + + + + + +@endpush + +@push('scripts') + +@endpush \ No newline at end of file diff --git a/resources/views/vpns/index.blade.php b/resources/views/vpns/index.blade.php new file mode 100644 index 0000000..56abcce --- /dev/null +++ b/resources/views/vpns/index.blade.php @@ -0,0 +1,505 @@ +@extends('layouts.masterbeta') + +@section('title', 'Click ERP - VPN Management') + +@section('breadcrumbs') + + + VPN Management +@endsection + +@section('content') +
+
+

VPN Management

+

Manage IPsec configurations with partners.

+
+
+ + + Standard Parameters + + + +
+
+ +
+ +
+
+
+
+ + +
+
+
+
+ +
+ + + + + + + + + + + + @forelse($vpns as $vpn) + + + + + + + + @empty + + + + @endforelse + +
PartnerGateways (Local ➔ Peer)Encryption Domains (Local ➔ Remote)StatusActions
{{ $vpn->partner_name }} +
+ {{ $vpn->local_ip }} + + {{ $vpn->peer_ip }} +
+
+
+ {{ $vpn->encryption_domain_local ?? 'Any' }} + + {{ $vpn->encryption_domain_remote ?? 'Any' }} +
+
+ + {{ $vpn->status }} + + + + +
+ + No VPN configurations added yet. +
+
+ + @if($vpns->hasPages()) + + @endif +
+@endsection + +@push('modals') + + + + + + + + + + +@endpush + +@push('scripts') + +@endpush \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 2c9fe8d..ba93ee7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -55,6 +55,10 @@ Route::middleware(['auth'])->group(function () { Route::resource('clients', App\Http\Controllers\ClientsController::class); #MNOs mnos.store + Route::get('/mnos/json/{id}', [App\Http\Controllers\NetworkOperatorsController::class, 'getJson'])->name('mnos.json'); + Route::post('/mnos/{operatorId}/documents', [App\Http\Controllers\NetworkOperatorsController::class, 'storeDocument'])->name('mnos.documents.store'); + Route::delete('/mnos/documents/{id}', [App\Http\Controllers\NetworkOperatorsController::class, 'destroyDocument'])->name('mnos.documents.destroy'); + Route::post('/mnos/{id}/connections', [App\Http\Controllers\NetworkOperatorsController::class, 'storeConnection'])->name('mnos.connections.store'); Route::delete('/mnos/connections/{id}', [App\Http\Controllers\NetworkOperatorsController::class, 'destroyConnection'])->name('mnos.connections.destroy'); Route::get('/mnos/{id}', [App\Http\Controllers\NetworkOperatorsController::class, 'show'])->name('mnos.show'); @@ -134,7 +138,29 @@ Route::middleware(['auth'])->group(function () { // Add a manual/custom holiday Route::post('holidays', [App\Http\Controllers\PublicHolidaysController::class, 'store']); + + // Management Dashboard + Route::get('/management', [App\Http\Controllers\ManagementDashboardController::class, 'index'])->name('management.index'); + Route::put('/management/leaves/{id}', [App\Http\Controllers\ManagementDashboardController::class, 'updateLeaveStatus'])->name('management.leaves.update'); + // Delete a holiday Route::delete('holidays/{holiday}', [App\Http\Controllers\PublicHolidaysController::class, 'destroy']); + + Route::resource('servers', App\Http\Controllers\ServerController::class); + + // OTP Security Routes + Route::post('/servers/{server}/request-otp', [App\Http\Controllers\ServerCredentialController::class, 'requestOtp'])->name('servers.request-otp'); + Route::post('/servers/{server}/verify-otp', [App\Http\Controllers\ServerCredentialController::class, 'verifyOtp'])->name('servers.verify-otp'); + + + Route::get('/vpns/standard-template', [App\Http\Controllers\VpnConfigController::class, 'downloadStandardTemplate'])->name('vpns.standard-template'); + Route::resource('vpns', App\Http\Controllers\VpnConfigController::class); + Route::get('/vpns/{vpn}/template', [App\Http\Controllers\VpnConfigController::class, 'generateTemplate'])->name('vpns.template'); + Route::post('/vpns/download', [App\Http\Controllers\VpnConfigController::class, 'download'])->name('vpns.download'); + + + + Route::get('/activity-logs', [App\Http\Controllers\ActivityLogController::class, 'index'])->name('activity-logs.index'); + Route::post('/logout', [App\Http\Controllers\AuthController::class, 'logout'])->name('logout'); }); diff --git a/scratch.md b/scratch.md index e3e753e..cbc8a66 100644 --- a/scratch.md +++ b/scratch.md @@ -1,3 +1,12 @@ + + + +-- contract_validity is now next_renewal_date _-- next_renewal_date | contract_validity + +-- next_renewal_date column type is supposed to be DATE + +contract_auto_renew should be changed from YES/NO to 0/1 + `name` varchar(200) NOT NULL DEFAULT '', `country` varchar(200) NOT NULL DEFAULT '', `contact_person` varchar(191) DEFAULT NULL,