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(` +
Monitor user actions, data modifications, and security events.
+| Timestamp | +User | +Action | +Entity Context | +Details | +
|---|---|---|---|---|
|
+ {{ $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. + | +||||
| Staff Member | +Leave Type | +Duration | +Requested On | +Actions | +
|---|---|---|---|---|
| {{ $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. + | +||||
| Staff Member | +Designation | +Reports Submitted | +Performance (Approx) | +
|---|---|---|---|
| + {{ $staff->name }} + | +{{ $staff->designation ?? 'Staff' }} | ++ {{ $staff->daily_reports_count }} + / ~{{ $workDays }} expected + | +
+
+ {{ $completionPercentage }}%
+
+
+
+
+ |
+
{{ $operator->country }} • - - {{ $operator->connection_status ?? 'Offline' }} + + {{ $operator->connection_status ?? 'Inactive' }}
| Document Name | +File Type | +Uploaded On | +Actions | +
|---|---|---|---|
| {{ $doc->name }} | +{{ strtoupper(pathinfo($doc->file_path, PATHINFO_EXTENSION)) }} | +{{ $doc->created_at->format('d M Y') }} | ++ + View + + + | +
| No documents uploaded yet. | +|||
Server list
+| Server / IP | +OS Version | +Services | +Status | +Actions | +
|---|---|---|---|---|
|
+ {{ $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. + | +||||
Manage SMS, USSD, and Voice short codes and validity periods.
+Manage SMS, USSD, and Voice short codes and their expiry dates.