diff --git a/README.md b/README.md index 9639f94e..b8a504d4 100644 --- a/README.md +++ b/README.md @@ -1,56 +1,3 @@ -DONT FORGET TO BUILT IT -THIS CHANGES NEEDS TO BE DONE: -```lua -CREATE TABLE IF NOT EXISTS mdt_patrols ( - id VARCHAR(64) PRIMARY KEY, - name VARCHAR(64) NOT NULL, - color VARCHAR(7) NOT NULL, - zone_points LONGTEXT NULL DEFAULT NULL, - sort_order INT NOT NULL DEFAULT 0, - member_ids TEXT NOT NULL DEFAULT '[]' -); - -CREATE TABLE IF NOT EXISTS `mdt_bulletin_posts` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `title` VARCHAR(255) NOT NULL, - `content` LONGTEXT NOT NULL, - `author` VARCHAR(100) NOT NULL, - `author_rank` VARCHAR(100) NOT NULL DEFAULT '', - `category` VARCHAR(48) NOT NULL DEFAULT 'general', - `priority` ENUM( - 'low', - 'normal', - 'high', - 'urgent' - ) NOT NULL DEFAULT 'normal', - `pinned` TINYINT(1) NOT NULL DEFAULT 0, - `job` VARCHAR(50) NOT NULL DEFAULT '', - `created_by` VARCHAR(60) NOT NULL, - `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - `updated_at` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, - `deleted_at` DATETIME NULL DEFAULT NULL, - PRIMARY KEY (`id`), - KEY `idx_job` (`job`), - KEY `idx_category` (`category`), - KEY `idx_pinned` (`pinned`), - KEY `idx_deleted_at` (`deleted_at`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - -CREATE TABLE IF NOT EXISTS `mdt_bulletin_categories` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `job` VARCHAR(50) NOT NULL, - `value` VARCHAR(48) NOT NULL, - `label` VARCHAR(48) NOT NULL, - `icon` VARCHAR(48) NOT NULL DEFAULT 'label', - `color` VARCHAR(7) NOT NULL DEFAULT '#6B7280', - `sort_order` SMALLINT NOT NULL DEFAULT 0, - `is_default` TINYINT(1) NOT NULL DEFAULT 0, - PRIMARY KEY (`id`), - UNIQUE KEY `uq_job_value` (`job`, `value`), - INDEX `idx_job_order` (`job`, `sort_order`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=UTF8MB4_UNICODE_CI; -``` - # ps-mdt v3 Police MDT (Mobile Data Terminal) for FiveM. Built with Svelte 5 and Lua. Works on QBCore and QBX through the ps_lib abstraction layer. @@ -358,4 +305,4 @@ We host some of the biggest FiveM servers in the industry such as Prodigy RP, Sm - Free transfer of files and setup - Free Windows licenses - Windows Remote Desktop -- 24/7 Support with ~30 min average ticket response +- 24/7 Support with ~30 min average ticket response \ No newline at end of file diff --git a/client/animation.lua b/client/animation.lua index 0ac64766..e68dd589 100644 --- a/client/animation.lua +++ b/client/animation.lua @@ -1,7 +1,7 @@ local tabletProp = nil local isPlayingTabletAnim = false -local animDict = Config.Animation and Config.Animation.Dict or 'amb@code_human_in_bus_passenger_idles@female@tablet@idle_a' -local animName = Config.Animation and Config.Animation.Name or 'idle_a' +local animDict = Config.Animation and Config.Animation.Dict or 'amb@world_human_tourist_map@male@base' +local animName = Config.Animation and Config.Animation.Name or 'base' local propModel = 'ps-mdt' -- Helper to always get current ped (never stale) @@ -12,10 +12,10 @@ end -- Options ---------------------------------- local propOptions = { - xPos = -0.050, -- X-axis offset from the center of entity2 - yPos = 0.0, -- Y-axis offset from the center of entity2 + xPos = 0.0, -- X-axis offset from the center of entity2 + yPos = -0.03, -- Y-axis offset from the center of entity2 zPos = 0.0, -- Z-axis offset from the center of entity2 - xRot = 0.0, -- X-axis rotation + xRot = 20.0, -- X-axis rotation yRot = -90.0, -- Y-axis rotation zRot = 0.0, -- Z-axis rotation p9 = true, -- Unknown diff --git a/client/backend/bodycams.lua b/client/backend/bodycams.lua index aaeac0ed..f10fa0d0 100644 --- a/client/backend/bodycams.lua +++ b/client/backend/bodycams.lua @@ -22,7 +22,7 @@ RegisterNUICallback('viewBodycam', function(data, cb) local result = ps.callback(resourceName .. ':server:viewBodycam', bodycamId) if result and result.success then - CloseMDT(true) + CloseMDT() cb({ success = true }) else cb({ success = false, message = result and result.error or 'Failed to view bodycam' }) diff --git a/client/backend/bulletinboard.lua b/client/backend/bulletinboard.lua deleted file mode 100644 index bbd989e8..00000000 --- a/client/backend/bulletinboard.lua +++ /dev/null @@ -1,86 +0,0 @@ -local resourceName = tostring(GetCurrentResourceName()) - --- Get all bulletin posts for the officer's department -RegisterNUICallback('getBulletinPosts', function(data, cb) - local result = ps.callback(resourceName .. ':server:getBulletinPosts') - cb(result or {}) -end) - --- Create a new bulletin post -RegisterNUICallback('createBulletinPost', function(data, cb) - if not data then cb({ success = false }) return end - local result = ps.callback(resourceName .. ':server:createBulletinPost', data) - cb(result or { success = false }) -end) - --- Update an existing bulletin post -RegisterNUICallback('updateBulletinPost', function(data, cb) - if not data or not data.id then cb({ success = false }) return end - local postId = data.id - data.id = nil - local result = ps.callback(resourceName .. ':server:updateBulletinPost', postId, data) - cb(result or { success = false }) -end) - --- Delete a bulletin post -RegisterNUICallback('deleteBulletinPost', function(data, cb) - if not data or not data.id then cb({ success = false }) return end - local result = ps.callback(resourceName .. ':server:deleteBulletinPost', data.id) - cb(result or { success = false }) -end) - --- Toggle pin on a bulletin post (supervisor only) -RegisterNUICallback('toggleBulletinPin', function(data, cb) - if not data or not data.id then cb({ success = false }) return end - local result = ps.callback(resourceName .. ':server:toggleBulletinPin', data.id) - cb(result or { success = false }) -end) - --- ── Category management ─────────────────────────────────────── - --- Get all categories for the current job -RegisterNUICallback('getBulletinCategories', function(data, cb) - local result = ps.callback(resourceName .. ':server:getBulletinCategories') - cb(result or {}) -end) - --- Add a new category -RegisterNUICallback('addBulletinCategory', function(data, cb) - if not data or not data.label or data.label == '' then - cb({ success = false, error = 'Missing required fields' }) - return - end - local result = ps.callback(resourceName .. ':server:addBulletinCategory', data) - cb(result or { success = false }) -end) - --- Update an existing category (label, icon, color, sort_order) -RegisterNUICallback('updateBulletinCategory', function(data, cb) - if not data or not data.value then - cb({ success = false, error = 'Missing category value' }) - return - end - local result = ps.callback(resourceName .. ':server:updateBulletinCategory', data) - cb(result or { success = false }) -end) - --- Remove a category by value -RegisterNUICallback('removeBulletinCategory', function(data, cb) - if not data or not data.value then - cb({ success = false, error = 'Missing category value' }) - return - end - local result = ps.callback(resourceName .. ':server:removeBulletinCategory', data) - cb(result or { success = false }) -end) - --- Reorder categories (bulk sort_order update) -RegisterNUICallback('reorderBulletinCategories', function(data, cb) - if not data or not data.order then - cb({ success = false, error = 'Missing order data' }) - return - end - -- Pass the whole data table so server can read data.order reliably - local result = ps.callback(resourceName .. ':server:reorderBulletinCategories', data) - cb(result or { success = false }) -end) \ No newline at end of file diff --git a/client/backend/cameras.lua b/client/backend/cameras.lua index 57566958..2c1ac1a1 100644 --- a/client/backend/cameras.lua +++ b/client/backend/cameras.lua @@ -22,7 +22,7 @@ RegisterNUICallback('viewCamera', function(data, cb) local result = ps.callback(resourceName .. ':server:viewCamera', cameraId) if result and result.success then - CloseMDT(true) + CloseMDT() cb({ success = true }) else cb({ success = false, message = result and result.error or 'Failed to view camera' }) diff --git a/client/backend/cases.lua b/client/backend/cases.lua index 5f27c5ee..3629ce2b 100644 --- a/client/backend/cases.lua +++ b/client/backend/cases.lua @@ -190,22 +190,12 @@ RegisterNUICallback('addEvidenceItem', function(data, cb) return end - local payload = data or {} - - if payload.evidence then - local result = ps.callback( - resourceName .. ':server:addEvidenceItem', - payload - ) - cb(result or { success = false }) - else - local result = ps.callback( - resourceName .. ':server:addEvidenceItem', - data.caseId, - data.evidence - ) - cb(result or { success = false }) - end + local result = ps.callback( + resourceName .. ':server:addEvidenceItem', + data.caseId, + data.evidence + ) + cb(result or { success = false }) end) RegisterNUICallback('updateEvidenceItem', function(data, cb) diff --git a/client/backend/citizens.lua b/client/backend/citizens.lua index 703ad80a..1b83edb0 100644 --- a/client/backend/citizens.lua +++ b/client/backend/citizens.lua @@ -6,38 +6,6 @@ RegisterNUICallback('getMyProfile', function(data, cb) cb(result or { success = false }) end) -RegisterNUICallback('getProperty', function(data, cb) - if not MDTOpen then cb({ success = false }) return end - if not data or not data.property_id then - cb({ success = false, message = 'Missing property id' }) - return - end - local result = ps.callback(resourceName .. ':server:getProperty', data.property_id) - if not result or not result.success then - cb(result or { success = false, message = 'Property not found' }) - return - end - if result.property and result.property.coords then - local coords = result.property.coords - local street1, street2 = GetStreetNameAtCoord(coords.x, coords.y, coords.z, 1.0) - local s1 = street1 and GetStreetNameFromHashKey(street1) or nil - local s2 = street2 and GetStreetNameFromHashKey(street2) or nil - if s1 and s1 ~= '' then - result.property.streetName = (s2 and s2 ~= '') and (s1 .. ' / ' .. s2) or s1 - end - end - cb(result) -end) - --- setWaypoint: sets a GPS blip on the map from NUI coords --- The NUI calls fetchNui('setWaypoint', { x, y }) — no server round-trip needed. -RegisterNUICallback('setWaypoint', function(data, cb) - cb({}) - if not data or not data.x or not data.y then return end - SetNewWaypoint(data.x, data.y) -end) - - RegisterNUICallback('getCitizens', function(data, cb) if not MDTOpen then cb({}) return end if type(data) ~= 'table' then diff --git a/client/backend/collab.lua b/client/backend/collab.lua index 8ac26655..724f1a41 100644 --- a/client/backend/collab.lua +++ b/client/backend/collab.lua @@ -27,31 +27,6 @@ RegisterNUICallback('syncReportData', function(data, cb) TriggerServerEvent(resourceName .. ':server:collabSyncData', data.reportId, data.dataType, data.data) end) -local awarenessIncomingBuffer = {} - -RegisterNUICallback('syncAwareness', function(data, cb) - TriggerServerEvent(resourceName .. ':server:collabSyncAwareness', data.reportId, data.update) - cb({ ok = true }) -end) - -RegisterNetEvent(resourceName .. ':client:awarenessBatch') -AddEventHandler(resourceName .. ':client:awarenessBatch', function(payload) - if not payload or not payload.updates then return end - for _, u in ipairs(payload.updates) do - awarenessIncomingBuffer[#awarenessIncomingBuffer + 1] = u - end -end) - -RegisterNUICallback('pollAwareness', function(data, cb) - if #awarenessIncomingBuffer == 0 then - cb({ updates = {} }) - return - end - local batch = awarenessIncomingBuffer - awarenessIncomingBuffer = {} - cb({ updates = batch }) -end) - -- Server push events -> forward to NUI RegisterNetEvent(resourceName .. ':client:reportEditorJoined', function(data) SendNUI('reportEditorJoined', data) diff --git a/client/backend/dashboard.lua b/client/backend/dashboard.lua index e3319fc5..a56db50b 100644 --- a/client/backend/dashboard.lua +++ b/client/backend/dashboard.lua @@ -60,9 +60,6 @@ function NUIUpdateAuth() citizenid = playerData.citizenid, job = playerData.job, charinfo = playerData.charinfo, - metadata = type(playerData.metadata) == 'table' and { - callsign = playerData.metadata.callsign or '', - } or nil, } or nil, isLEO = isAuthorized, onDuty = ps.getJobDuty() or false, @@ -177,32 +174,6 @@ RegisterNUICallback('deleteBulletin', function(data, cb) cb(result or { success = false }) end) -RegisterNUICallback('getBulletinCategories', function(_, cb) - local result = ps.callback(resourceName .. ':server:getBulletinCategories', false) - cb(result or {}) -end) - -RegisterNUICallback('saveBulletinCategories', function(data, cb) - if not data or not data.categories then - cb({ success = false, message = 'Invalid data' }) - return - end - - for _, cat in ipairs(data.categories) do - if type(cat.value) ~= 'string' or type(cat.label) ~= 'string' or type(cat.icon) ~= 'string' then - cb({ success = false, message = 'Malformed category entry' }) - return - end - if #cat.label > 32 or #cat.icon > 48 then - cb({ success = false, message = 'Category label or icon name too long' }) - return - end - end - - local result = ps.callback('mdt:server:saveBulletinCategories', false, data.categories) - cb(result or { success = false, message = 'Server error' }) -end) - -- RECENT REPORTS ------------------------------------- RegisterNUICallback('getRecentReports', function(data, cb) diff --git a/client/backend/officers.lua b/client/backend/officers.lua index 16475bb7..7fb7a99f 100644 --- a/client/backend/officers.lua +++ b/client/backend/officers.lua @@ -6,10 +6,12 @@ RegisterNUICallback('setCallsign', function(data, cb) cb({ success = false, message = 'MDT is not open' }) return end + if type(data) ~= 'table' or (not data.cid and not data.citizenid) or (not data.newcallsign and not data.callsign) then cb({ success = false, message = 'Missing citizen ID or callsign' }) return end + local result = ps.callback(resourceName .. ':server:setCallsign', { citizenid = data.cid or data.citizenid, callsign = data.newcallsign or data.callsign, @@ -17,14 +19,6 @@ RegisterNUICallback('setCallsign', function(data, cb) cb(result or { success = false, message = 'Failed to set callsign' }) end) -RegisterNUICallback('getCallsign', function(data, cb) - if not MDTOpen then cb({ callsign = '' }) return end - local result = ps.callback(resourceName .. ':server:getCallsign', { - citizenid = data.citizenid, - }) - cb(result or { callsign = '' }) -end) - -- Set Radio Frequency RegisterNUICallback('setRadio', function(data, cb) if not MDTOpen then diff --git a/client/backend/reports.lua b/client/backend/reports.lua index 0918e2d5..85306895 100644 --- a/client/backend/reports.lua +++ b/client/backend/reports.lua @@ -83,8 +83,8 @@ RegisterNUICallback('saveReport', function(data, cb) if victim.citizenid then table.insert(involved, { citizenid = victim.citizenid, - type = victim.type or 'victim', - notes = victim.notes or '' + type = 'victim', + notes = '' }) end end @@ -106,12 +106,9 @@ RegisterNUICallback('saveReport', function(data, cb) if data.evidence then for _, item in ipairs(data.evidence) do table.insert(evidence, { - title = item.title or '', type = item.type or 'Evidence', - content = item.serial or '', - note = item.notes or '', - stored = item.stored or 0, - images = item.images or {} + content = item.serial or (item.images and item.images[1]) or item.title or '', + note = item.notes or '' }) end end @@ -315,11 +312,6 @@ RegisterNUICallback('searchVehiclesForReport', function(data, cb) end local query = data and data.query or '' - if query == '' then - cb({}) - return - end - local result = ps.callback(resourceName .. ':server:searchVehiclesForReport', query) cb(result or {}) -end) \ No newline at end of file +end) diff --git a/client/backend/tracking.lua b/client/backend/tracking.lua index 06407428..ce59dc51 100644 --- a/client/backend/tracking.lua +++ b/client/backend/tracking.lua @@ -1,376 +1,15 @@ --- ---------------------------------------------------------------------------- --- Responsibilities: --- * Forwards patrol/tracking data between server and the map NUI. --- * Maintains one ox_lib PolyZone per patrol and fires enter/exit --- notifications, but ONLY for the patrol the local player belongs to. --- * Zones live independently of the MDT: they are built on spawn and on --- resource start, so notifications work without ever opening the MDT. --- --- Performance notes for future devs: --- * syncPatrols is broadcast to ALL clients on EVERY patrol mutation --- (create/delete/assign/remove/rename/reorder/zone). syncZones() therefore --- diffs each zone's geometry signature and only rebuilds zones whose points --- actually changed — a rename/reorder/assign no longer tears down and --- recreates every PolyZone on every client. --- * Patrol names shown in zone notifications are read live from --- zonePatrolNames, so a rename never forces a zone rebuild. --- --- Set MDT_DEBUG = true for verbose console logging while developing. --- ============================================================================ - local resourceName = tostring(GetCurrentResourceName()) --- ─── Tunables ─────────────────────────────────────────────────────────────── -local MDT_DEBUG = false -- verbose dev logging; KEEP FALSE on production - -local function dbg(...) - if MDT_DEBUG then print('[MDT]', ...) end -end - --- UI state held in Lua client (survives MDT open/close) -local mapUiState = { - sidebarOpen = true, - officersOpen = true, - patrolsOpen = true, -} - --- ─── Patrol zone notification preference ───────────────────────────────────── --- Read from localStorage via a NUI message on resource start. --- Defaults to true (enabled) until the NUI reports otherwise. -local patrolZoneNotificationsEnabled = true - -AddEventHandler('onClientResourceStart', function(res) - if res ~= resourceName then return end - -- Ask the NUI to send us the stored preference - SendNUIMessage({ type = 'requestPatrolZonePref' }) -end) - --- The NUI page reads localStorage and sends this back -RegisterNUICallback('patrolZonePref', function(data, cb) - if type(data.enabled) == 'boolean' then - patrolZoneNotificationsEnabled = data.enabled - end - cb({}) -end) - --- ─── Notification helper (works with QBCore, ox_lib, ps-ui) ───────────────── - -local function mdtNotify(title, description, notifyType, duration) - -- Respect the player's preference stored in localStorage via NUI - if not patrolZoneNotificationsEnabled then return end - duration = duration or 4000 - if exports['ox_lib'] then - lib.notify({ title = title, description = description, type = notifyType, duration = duration }) - elseif exports['qb-core'] then - exports['qb-core']:GetCoreObject().Functions.Notify(('[%s] %s'):format(title, description), notifyType, duration) - elseif exports['ps-ui'] then - exports['ps-ui']:Notify({ text = ('[%s] %s'):format(title, description), type = notifyType, length = duration }) - else - BeginTextCommandThefeedPost('STRING') - AddTextComponentSubstringPlayerName(('[%s] %s'):format(title, description)) - EndTextCommandThefeedPostTick() - end -end - --- ─── Patrol Zone tracking ────────────────────────────────────────────────── --- Keeps one PolyZone per patrol id. Zones are (re)built only when their --- geometry changes (see syncZones). - -local activeZones = {} -- [patrolId] = ox_lib PolyZone object -local zoneSignatures = {} -- [patrolId] = geometry signature (string) -local zonePatrolNames = {} -- [patrolId] = latest patrol name (read live by callbacks) -local myPatrolId = nil -- patrol the local player currently belongs to - -local function getCitizenId() - if exports['qb-core'] then - return exports['qb-core']:GetCoreObject().Functions.GetPlayerData().citizenid - elseif ps and ps.getIdentifier then - return ps.getIdentifier() - end - return nil -end - -local function destroyZone(patrolId) - if activeZones[patrolId] then - activeZones[patrolId]:remove() - activeZones[patrolId] = nil - end -end - -local function destroyAllZones() - for id in pairs(activeZones) do - destroyZone(id) - end - zoneSignatures = {} - zonePatrolNames = {} -end - --- A cheap, stable string fingerprint of a zone's points. Identical geometry => --- identical signature => no rebuild needed. -local function pointsSignature(points) - if not points then return "" end - local parts = {} - for i = 1, #points do - parts[i] = string.format("%.1f,%.1f", points[i].x or 0.0, points[i].y or 0.0) - end - return table.concat(parts, ";") -end - --- Convert GTA world {x,y} array → ox_lib poly-zone points (vector3; z required, --- but ignored by lib.zones.poly). -local function toVector3List(points) - local vecs = {} - for i = 1, #points do - vecs[i] = vector3(points[i].x, points[i].y, 0.0) - end - return vecs -end - -local function createZoneForPatrol(patrol) - destroyZone(patrol.id) - - local points = patrol.zonePoints - if not points or #points < 3 then return end - - local vecs = toVector3List(points) - local pid = patrol.id - - -- Suppress the initial onEnter that fires when the zone is first created - -- (happens on spawn, MDT open, resource restart) - local suppressInitial = true - SetTimeout(2500, function() suppressInitial = false end) - - local zone = lib.zones.poly({ - points = vecs, - thickness = 1600, - debug = false, - onEnter = function() - if pid ~= myPatrolId then return end - if suppressInitial then return end - mdtNotify(zonePatrolNames[pid] or 'Patrol', 'entered Zone', 'success', 3000) - end, - onExit = function() - if pid ~= myPatrolId then return end - mdtNotify(zonePatrolNames[pid] or 'Patrol', 'left Zone', 'error', 5000) - end, - }) - - activeZones[pid] = zone -end - --- Rebuild only the zones whose geometry changed, and figure out which patrol the --- local player is in. Cheap to call on every syncPatrols broadcast. -local function syncZones(patrols) - local citizenId = getCitizenId() - myPatrolId = nil - - -- Destroy zones for patrols that no longer exist - local incoming = {} - for _, p in ipairs(patrols) do incoming[p.id] = true end - for id in pairs(activeZones) do - if not incoming[id] then - destroyZone(id) - zoneSignatures[id] = nil - zonePatrolNames[id] = nil - end - end - - for _, patrol in ipairs(patrols) do - -- Keep the name fresh so renames update notifications without a rebuild - zonePatrolNames[patrol.id] = patrol.name - - -- Resolve local membership - if citizenId then - for _, mid in ipairs(patrol.memberIds) do - if mid == citizenId then - myPatrolId = patrol.id - break - end - end - end - - local hasZone = patrol.zonePoints and #patrol.zonePoints >= 3 - if hasZone then - local sig = pointsSignature(patrol.zonePoints) - if zoneSignatures[patrol.id] ~= sig then - createZoneForPatrol(patrol) - zoneSignatures[patrol.id] = sig - dbg(('rebuilt zone for "%s" (%d pts)'):format(patrol.name, #patrol.zonePoints)) - end - else - if activeZones[patrol.id] then destroyZone(patrol.id) end - zoneSignatures[patrol.id] = nil - end - end -end - --- ─── Server → NUI ───────────────────────────────────────────────────────── - -RegisterNetEvent(resourceName .. ":client:syncPatrols", function(patrols, action, citizenid) - SendNUIMessage({ type = "syncPatrols", data = patrols, action = action, citizenid = citizenid }) - - -- Rebuild PolyZones whenever patrol data changes (diffed inside syncZones) - if type(patrols) == "table" then - syncZones(patrols) - end -end) - --- Called from client.lua after setVisible(true) — send citizenId so map centers on self -function SendMapCitizenId() - local cid = getCitizenId() - if cid then - SendNUIMessage({ type = 'setLocalCitizenId', data = { citizenid = cid } }) - end -end - -RegisterNetEvent(resourceName .. ':client:checkVehicleClass', function(netId, plate, coords, heading) - local veh = NetworkGetEntityFromNetworkId(netId) - if not veh or veh == 0 then return end - if GetVehicleClass(veh) ~= 18 then return end -- 18 = Emergency - TriggerServerEvent(resourceName .. ':server:cacheVehicle', plate, coords, heading) -end) - --- ─── UI State ───────────────────────────────────────────────────────────── - -function SendMapUiState() - SendNUIMessage({ type = "mapUiState", data = mapUiState }) -end - -RegisterNUICallback("saveMapUiState", function(data, cb) - if type(data.key) == "string" and type(data.value) == "boolean" then - mapUiState[data.key] = data.value - end - cb({}) -end) - --- ─── Tracking ───────────────────────────────────────────────────────────── - -RegisterNUICallback("getTracking", function(_, cb) +RegisterNUICallback('getTracking', function(_, cb) if not MDTOpen then - cb({ success = false, data = { vehicles = {}, bodycams = {} } }) + cb({ success = false, message = 'MDT is not open', data = {} }) return end - local tracking = ps.callback(resourceName .. ":server:getTracking") + local tracking = ps.callback(resourceName .. ':server:getTracking') if tracking then cb({ success = true, data = tracking }) else - cb({ success = false, data = { vehicles = {}, bodycams = {} } }) - end -end) - --- ─── Patrols ────────────────────────────────────────────────────────────── - -RegisterNUICallback("getPatrols", function(_, cb) - local result = ps.callback(resourceName .. ":server:getPatrols") - local patrols = result or {} - cb({ success = true, data = patrols }) - -- Also sync zones immediately on load - if type(patrols) == "table" then - syncZones(patrols) - end -end) - -RegisterNUICallback("createPatrol", function(data, cb) - if not MDTOpen then cb({ success = false }) return end - local id, name, color = data.id, data.name, data.color - if type(id) ~= "string" or type(name) ~= "string" or type(color) ~= "string" then - cb({ success = false }) return - end - TriggerServerEvent(resourceName .. ":server:createPatrol", id, name, color) - cb({ success = true }) -end) - -RegisterNUICallback("deletePatrol", function(data, cb) - if not MDTOpen then cb({ success = false }) return end - if type(data.id) ~= "string" then cb({ success = false }) return end - TriggerServerEvent(resourceName .. ":server:deletePatrol", data.id) - cb({ success = true }) -end) - -RegisterNUICallback("renamePatrol", function(data, cb) - if not MDTOpen then cb({ success = false }) return end - if type(data.id) ~= "string" or type(data.name) ~= "string" then - cb({ success = false }) return + cb({ success = false, message = 'Failed to fetch tracking data', data = {} }) end - TriggerServerEvent(resourceName .. ":server:renamePatrol", data.id, data.name) - cb({ success = true }) end) - -RegisterNUICallback("assignOfficer", function(data, cb) - if not MDTOpen then cb({ success = false }) return end - if type(data.patrolId) ~= "string" or type(data.citizenId) ~= "string" then - cb({ success = false }) return - end - TriggerServerEvent(resourceName .. ":server:assignOfficer", data.patrolId, data.citizenId) - cb({ success = true }) -end) - -RegisterNUICallback("reorderPatrols", function(data, cb) - if not MDTOpen then cb({ success = false }) return end - if type(data.ids) ~= "table" then cb({ success = false }) return end - TriggerServerEvent(resourceName .. ":server:reorderPatrols", data.ids) - cb({ success = true }) -end) - -RegisterNUICallback("removeFromPatrol", function(data, cb) - if not MDTOpen then cb({ success = false }) return end - if type(data.citizenId) ~= "string" then cb({ success = false }) return end - TriggerServerEvent(resourceName .. ":server:removeFromPatrol", data.citizenId) - cb({ success = true }) -end) - --- ─── Cleanup ────────────────────────────────────────────────────────────── - -AddEventHandler("onResourceStop", function(res) - if res ~= resourceName then return end - destroyAllZones() -end) - --- ─── Auto-init zones on spawn (independent of MDT) ──────────────────────── --- Loads patrol zone data from the server as soon as the player spawns, so zone --- entry/exit notifications work without ever opening the MDT. - -local function initZonesFromServer() - -- Send citizenId so the map can center on the officer's own position - local cid = getCitizenId() - if cid then - SendNUIMessage({ type = 'setLocalCitizenId', data = { citizenid = cid } }) - end - - local result = ps.callback(resourceName .. ":server:getPatrols") - if type(result) == "table" then - syncZones(result) - end -end - --- Fires when the player first spawns -RegisterNetEvent('QBCore:Client:OnPlayerLoaded', function() - -- Small delay so the framework has finished setting up the player - SetTimeout(2000, initZonesFromServer) -end) - --- ─── Zone Drawing ───────────────────────────────────────────────────────── --- Called by the Svelte map after the user finishes drawing a zone polygon. --- data.id = patrol id (string) --- data.points = array of { x, y } in GTA world coordinates, or nil to clear -RegisterNUICallback("setPatrolZone", function(data, cb) - if not MDTOpen then cb({ success = false }) return end - if type(data.id) ~= "string" then cb({ success = false }) return end - - -- Validate points on client before sending to server - local points = data.points - if points ~= nil then - if type(points) ~= "table" or #points < 3 then - cb({ success = false, error = "need_3_points" }) return - end - for _, pt in ipairs(points) do - if type(pt) ~= "table" or type(pt.x) ~= "number" or type(pt.y) ~= "number" then - cb({ success = false, error = "invalid_point" }) return - end - end - end - - TriggerServerEvent(resourceName .. ":server:setPatrolZone", data.id, points) - cb({ success = true }) -end) \ No newline at end of file diff --git a/client/backend/weapons.lua b/client/backend/weapons.lua index 79532dae..fb7fa3c4 100644 --- a/client/backend/weapons.lua +++ b/client/backend/weapons.lua @@ -14,12 +14,6 @@ RegisterNUICallback('getWeaponBolos', function(data, cb) cb(result) end) -RegisterNUICallback('saveWeaponFlags', function(data, cb) - local flags = type(data.flags) == 'table' and data.flags or json.decode(data.flags) or {} - local result = ps.callback(resourceName .. ':server:saveWeaponFlags', data.serial, flags) - cb(result or { success = false }) -end) - RegisterNUICallback('getWeaponOwnershipHistory', function(data, cb) if not MDTOpen then cb({}) return end if not data or not data.serial then diff --git a/client/cameras.lua b/client/cameras.lua index f0b5b85d..3db4fd91 100644 --- a/client/cameras.lua +++ b/client/cameras.lua @@ -50,7 +50,6 @@ local function stopCameraView(notifyServer) ClearTimecycleModifier() ps.debug('Clearing focus area') - StopTabletAnimation() ClearFocus() DoScreenFadeIn(250) @@ -196,19 +195,22 @@ updateCameraControls = function() if currentCameraData and currentCameraData.isBodycam and currentCameraData.targetSource then local targetPed = GetPlayerPed(GetPlayerFromServerId(currentCameraData.targetSource)) if targetPed and targetPed ~= 0 and DoesEntityExist(targetPed) then + -- SKEL_Head bone index = 31086 + local boneIndex = GetPedBoneIndex(targetPed, 31086) + local boneCoords = GetPedBoneCoords(targetPed, boneIndex, 0.0, 0.0, 0.0) + -- Offset slightly forward and up from the head to simulate chest/shoulder bodycam local forward = GetEntityForwardVector(targetPed) - local pedCoords = GetEntityCoords(targetPed) - - local camX = pedCoords.x + forward.x * 0.3 - local camY = pedCoords.y + forward.y * 0.3 - local camZ = pedCoords.z + 0.4 - + local camX = boneCoords.x + forward.x * 0.1 + local camY = boneCoords.y + forward.y * 0.1 + local camZ = boneCoords.z + 0.05 SetCamCoord(currentCamera, camX, camY, camZ) + -- Point camera in the direction the ped is facing local heading = GetEntityHeading(targetPed) - local camZ_rot = heading + local currentRot = GetCamRot(currentCamera, 2) + SetCamRot(currentCamera, currentRot.x, currentRot.y, -heading, 2) - SetCamRot(currentCamera, -10.0, 0.0, camZ_rot, 2) + -- Update focus area so world streams around the target SetFocusPosAndVel(camX, camY, camZ, 0, 0, 0) end end diff --git a/client/keys.lua b/client/keys.lua index 3f2f44f9..14f7677a 100644 --- a/client/keys.lua +++ b/client/keys.lua @@ -147,9 +147,7 @@ function OpenMDT() MDTOpen = true SendNUI('setVisible', { visible = true, debugMode = Config.Debug }) - SendMapCitizenId() - SendMapUiState() - + if isCivilian then -- Civilian mode: send auth with civilian flag local playerData = ps.getPlayerData() @@ -176,14 +174,11 @@ end -- Close MDT local closeControlsPending = false - -function CloseMDT(keepAnimation) +function CloseMDT() if MDTOpen then MDTOpen = false - if not keepAnimation then - StopTabletAnimation() - end + StopTabletAnimation() SendNUI('setVisible', { visible = false }) SetNuiFocus(false, false) diff --git a/config.lua b/config.lua index a3b10f62..9978e0a8 100644 --- a/config.lua +++ b/config.lua @@ -17,7 +17,7 @@ Config.CivilianAccess = { Config.DateTime = { GameTime = true, -- If set to true, the game time will be used instead of the server time (boolean) TimeFormat = '24', -- Format for displaying time ('24' or '12') - DateFormat = "DD-MM-YYYY" -- Format for displaying date (string: "MM-DD-YYYY", "DD-MM-YYYY", or "YYYY-MM-DD") + DateFormat = "MM-DD-YYYY" -- Format for displaying date (string: "MM-DD-YYYY", "DD-MM-YYYY", or "YYYY-MM-DD") } -- Department data sharing @@ -100,7 +100,7 @@ Config.FingerprintAutoFilled = false -- Auto-populate fingerprints on citizen pr -- Fingerprint Scan Integration Config.FingerprintScan = { - enabled = true, -- Enable fingerprint scan trigger from MDT + enabled = false, -- Enable fingerprint scan trigger from MDT officerEvent = 'police:client:showFingerprint', -- Client event triggered on the officer suspectEvent = 'police:client:showFingerprint', -- Client event triggered on the suspect } @@ -182,8 +182,8 @@ Config.CacheTTL = { -- Tablet Animation Config.Animation = { - Dict = 'amb@code_human_in_bus_passenger_idles@female@tablet@idle_a', - Name = 'idle_a', + Dict = 'amb@world_human_tourist_map@male@base', + Name = 'base', } -- Mugshot Camera @@ -218,7 +218,6 @@ Config.ManagementPermissions = { 'vehicles_edit_dmv', -- Weapons 'weapons_search', - 'weapons_add', -- Cases 'cases_view', 'cases_create', @@ -241,9 +240,6 @@ Config.ManagementPermissions = { 'charges_view', 'charges_edit', -- Dispatch - 'map_patrols_view', - "map_patrols_manage", - "map_patrols_edit", 'dispatch_attach', 'dispatch_route', -- Cameras & Bodycams @@ -260,10 +256,6 @@ Config.ManagementPermissions = { -- FTO 'fto_view', 'fto_manage', - -- BulletIn Board - 'bulletin_view', - 'bulletin_post', - 'bulletin_pin', -- Management 'management_permissions', 'management_bulletins', @@ -341,13 +333,3 @@ Config.CameraModels = { ['cctv_cam_08'] = 'p_cctv_s', ['cctv_cam_09'] = 'hei_prop_bank_cctv_02', } - --- Which Weapons should be allowed to be registered manually -Config.Weapons = { - { model = "weapon_heavypistol", label = "Heavy Pistol" }, - { model = "weapon_sniperrifle", label = "Hunting Rifle" }, - { model = "weapon_ceramicpistol", label = "Ceramic Pistol" }, - { model = "weapon_doubleaction", label = "Double-Action Revolver" }, - { model = "weapon_navyrevolver", label = "Navy Revolver" }, - { model = "weapon_musket", label = "Musket" }, -} \ No newline at end of file diff --git a/server/backend/bulletinboard.lua b/server/backend/bulletinboard.lua deleted file mode 100644 index 2d7817ac..00000000 --- a/server/backend/bulletinboard.lua +++ /dev/null @@ -1,502 +0,0 @@ -local resourceName = tostring(GetCurrentResourceName()) - --- ════════════════════════════════════════════════════════════ --- Bulletin Posts --- ════════════════════════════════════════════════════════════ - --- ── Get all bulletin posts for the officer's department ────── - -ps.registerCallback(resourceName .. ':server:getBulletinPosts', function(source) - local src = source - if not CheckAuth(src) then return {} end - - local jobName = ps.getJobName(src) - if not jobName or jobName == '' then return {} end - - local ok, posts = pcall(MySQL.query.await, [[ - SELECT - bp.id, bp.title, bp.content, bp.author, bp.author_rank, - bp.category, bp.priority, bp.pinned, bp.created_by, - bp.created_at, bp.updated_at, - COALESCE(bc.label, bp.category) AS category_label, - COALESCE(bc.color, '#6B7280') AS category_color - FROM mdt_bulletin_posts bp - LEFT JOIN mdt_bulletin_categories bc - ON bc.value = bp.category AND bc.job = bp.job - WHERE bp.job = ? - ORDER BY bp.pinned DESC, - FIELD(bp.priority, 'urgent', 'high', 'normal', 'low'), - bp.created_at DESC - ]], { jobName }) - - if not ok or not posts then return {} end - - for _, post in ipairs(posts) do - post.pinned = post.pinned == 1 or post.pinned == '1' or post.pinned == true - end - - return posts -end) - --- ── Create a bulletin post ──────────────────────────────────── - -ps.registerCallback(resourceName .. ':server:createBulletinPost', function(source, data) - local src = source - if not CheckAuth(src) then return { success = false, error = 'Unauthorized' } end - if not CheckPermission(src, 'bulletin_post') then - return { success = false, error = 'No permission to create bulletin posts' } - end - - data = data or {} - - -- Validate category exists for this job (or allow any stored value) - local jobName = ps.getJobName(src) - local jobRank = ps.getJobGradeName(src) - local citizenId = ps.getIdentifier(src) - - local title = tostring(data.title or ''):gsub('^%s+', ''):gsub('%s+$', '') - if title == '' then return { success = false, error = 'Title is required' } end - - local VALID_PRIORITIES = { low = true, normal = true, high = true, urgent = true } - if not VALID_PRIORITIES[data.priority] then - return { success = false, error = 'Invalid priority' } - end - - -- Check category exists for job - local catRow = MySQL.single.await( - 'SELECT value FROM mdt_bulletin_categories WHERE value = ? AND job = ?', - { data.category, jobName } - ) - if not catRow then - return { success = false, error = 'Invalid or unknown category' } - end - - local profile = MySQL.single.await( - 'SELECT fullname FROM mdt_profiles WHERE citizenid = ?', - { citizenId } - ) - local author = (profile and profile.fullname) or tostring(GetPlayerName(src) or 'Unknown') - - local canPin = CheckPermission(src, 'bulletin_pin') - local pinned = (canPin and data.pinned == true) and 1 or 0 - - local id = MySQL.insert.await([[ - INSERT INTO mdt_bulletin_posts - (title, content, author, author_rank, category, priority, pinned, job, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ]], { - title:sub(1, 255), - tostring(data.content or ''):sub(1, 65535), - author:sub(1, 100), - jobRank, - data.category, - data.priority, - pinned, - jobName, - citizenId - }) - - if not id then return { success = false, error = 'Database error' } end - return { success = true, id = id } -end) - --- ── Update a bulletin post ──────────────────────────────────── - -ps.registerCallback(resourceName .. ':server:updateBulletinPost', function(source, postId, updates) - local src = source - if not CheckAuth(src) then return { success = false, error = 'Unauthorized' } end - - postId = tonumber(postId) - updates = updates or {} - if not postId then return { success = false, error = 'Invalid post id' } end - - local existing = MySQL.single.await( - 'SELECT created_by, job FROM mdt_bulletin_posts WHERE id = ?', - { postId } - ) - if not existing then return { success = false, error = 'Post not found' } end - - local jobName = ps.getJobName(src) - local citizenId = ps.getIdentifier(src) - local isSupervisor = CheckPermission(src, 'bulletin_post') - local isOwner = existing.created_by == citizenId - - if not isOwner and not isSupervisor then - return { success = false, error = 'No permission to edit this post' } - end - if existing.job ~= jobName then - return { success = false, error = 'Post belongs to a different department' } - end - - local VALID_PRIORITIES = { low = true, normal = true, high = true, urgent = true } - - local sets = {} - local vals = {} - - if updates.title ~= nil then - local t = tostring(updates.title):gsub('^%s+', ''):gsub('%s+$', '') - if t ~= '' then - sets[#sets + 1] = 'title = ?' - vals[#vals + 1] = t:sub(1, 255) - end - end - if updates.content ~= nil then - sets[#sets + 1] = 'content = ?' - vals[#vals + 1] = tostring(updates.content):sub(1, 65535) - end - if updates.category ~= nil then - -- Validate category belongs to this job - local catRow = MySQL.single.await( - 'SELECT value FROM mdt_bulletin_categories WHERE value = ? AND job = ?', - { updates.category, jobName } - ) - if catRow then - sets[#sets + 1] = 'category = ?' - vals[#vals + 1] = updates.category - end - end - if updates.priority ~= nil and VALID_PRIORITIES[updates.priority] then - sets[#sets + 1] = 'priority = ?' - vals[#vals + 1] = updates.priority - end - if updates.pinned ~= nil and isSupervisor then - sets[#sets + 1] = 'pinned = ?' - vals[#vals + 1] = updates.pinned and 1 or 0 - end - - if #sets == 0 then return { success = false, error = 'No valid fields to update' } end - - vals[#vals + 1] = postId - MySQL.update.await( - 'UPDATE mdt_bulletin_posts SET ' .. table.concat(sets, ', ') .. ' WHERE id = ?', - vals - ) - return { success = true } -end) - --- ── Delete a bulletin post ──────────────────────────────────── - -ps.registerCallback(resourceName .. ':server:deleteBulletinPost', function(source, postId) - local src = source - if not CheckAuth(src) then return { success = false, error = 'Unauthorized' } end - - postId = tonumber(postId) - if not postId then return { success = false, error = 'Invalid post id' } end - - local existing = MySQL.single.await( - 'SELECT created_by, job FROM mdt_bulletin_posts WHERE id = ?', - { postId } - ) - if not existing then return { success = false, error = 'Post not found' } end - - local jobName = ps.getJobName(src) - local citizenId = ps.getIdentifier(src) - local isSupervisor = CheckPermission(src, 'bulletin_pin') - local isOwner = existing.created_by == citizenId - - if not isOwner and not isSupervisor then - return { success = false, error = 'No permission to delete this post' } - end - if existing.job ~= jobName then - return { success = false, error = 'Post belongs to a different department' } - end - - MySQL.update.await('DELETE FROM mdt_bulletin_posts WHERE id = ?', { postId }) - return { success = true } -end) - --- ── Toggle pin on a bulletin post ──────────────────────────── - -ps.registerCallback(resourceName .. ':server:toggleBulletinPin', function(source, postId) - local src = source - if not CheckAuth(src) then return { success = false, error = 'Unauthorized' } end - if not CheckPermission(src, 'bulletin_pin') then - return { success = false, error = 'No permission to pin posts' } - end - - postId = tonumber(postId) - if not postId then return { success = false, error = 'Invalid post id' } end - - local existing = MySQL.single.await( - 'SELECT pinned, job FROM mdt_bulletin_posts WHERE id = ?', - { postId } - ) - if not existing then return { success = false, error = 'Post not found' } end - - local jobName = ps.getJobName(src) - if existing.job ~= jobName then - return { success = false, error = 'Post belongs to a different department' } - end - - local isPinned = existing.pinned == 1 or existing.pinned == '1' or existing.pinned == true - local newPinned = isPinned and 0 or 1 - - MySQL.update.await( - 'UPDATE mdt_bulletin_posts SET pinned = ? WHERE id = ?', - { newPinned, postId } - ) - return { success = true, pinned = newPinned } -end) - --- ════════════════════════════════════════════════════════════ --- Category Management (one row per category) --- --- Table: mdt_bulletin_categories --- Columns: id, job, value, label, icon, color, sort_order, is_default --- ════════════════════════════════════════════════════════════ - --- Slugify a label into a safe DB value (e.g. "My Custom Cat" → "my_custom_cat") -local function slugify(str) - return tostring(str) - :lower() - :gsub('%s+', '_') - :gsub('[^%w_]', '') - :sub(1, 48) -end - --- Seed default categories for a job if none exist yet -local function ensureDefaultCategories(jobName) - local count = MySQL.scalar.await( - 'SELECT COUNT(*) FROM mdt_bulletin_categories WHERE job = ?', - { jobName } - ) - if (count or 0) > 0 then return end - - local defaults = { - { value = 'announcement', label = 'Announcements', icon = 'campaign', color = '#3B82F6', sort_order = 1 }, - { value = 'operations', label = 'Operations', icon = 'local_police', color = '#8B5CF6', sort_order = 2 }, - { value = 'training', label = 'Training', icon = 'school', color = '#10B981', sort_order = 3 }, - { value = 'general', label = 'General', icon = 'forum', color = '#6B7280', sort_order = 4 }, - } - - for _, cat in ipairs(defaults) do - MySQL.insert.await([[ - INSERT IGNORE INTO mdt_bulletin_categories - (job, value, label, icon, color, sort_order, is_default) - VALUES (?, ?, ?, ?, ?, ?, 1) - ]], { jobName, cat.value, cat.label, cat.icon, cat.color, cat.sort_order }) - end -end - --- ── GET categories ──────────────────────────────────────────── - -ps.registerCallback(resourceName .. ':server:getBulletinCategories', function(source) - local src = source - if not CheckAuth(src) then return {} end - - local jobName = ps.getJobName(src) - if not jobName or jobName == '' then return {} end - - ensureDefaultCategories(jobName) - - local ok, cats = pcall(MySQL.query.await, [[ - SELECT value, label, icon, color, sort_order, is_default - FROM mdt_bulletin_categories - WHERE job = ? - ORDER BY sort_order ASC, id ASC - ]], { jobName }) - - if not ok or not cats then return {} end - - for _, c in ipairs(cats) do - c.is_default = c.is_default == 1 or c.is_default == '1' or c.is_default == true - end - - return cats -end) - --- ── ADD category ────────────────────────────────────────────── - -ps.registerCallback(resourceName .. ':server:addBulletinCategory', function(source, data) - local src = source - if not CheckAuth(src) then return { success = false, error = 'Unauthorized' } end - if not CheckPermission(src, 'bulletin_post') then - return { success = false, error = 'No permission to manage categories' } - end - - data = data or {} - local jobName = ps.getJobName(src) - - local label = tostring(data.label or ''):gsub('^%s+', ''):gsub('%s+$', '') - if label == '' then return { success = false, error = 'Label is required' } end - - -- Build value from label if not provided - local value = (data.value and data.value ~= '') and slugify(data.value) or slugify(label) - if value == '' then return { success = false, error = 'Could not generate a valid category key' } end - - -- Check for duplicate - local existing = MySQL.scalar.await( - 'SELECT COUNT(*) FROM mdt_bulletin_categories WHERE job = ? AND value = ?', - { jobName, value } - ) - if (existing or 0) > 0 then - return { success = false, error = 'A category with that key already exists' } - end - - -- Max 20 categories per job - local total = MySQL.scalar.await( - 'SELECT COUNT(*) FROM mdt_bulletin_categories WHERE job = ?', - { jobName } - ) - if (total or 0) >= 20 then - return { success = false, error = 'Maximum of 20 categories per department reached' } - end - - local nextOrder = MySQL.scalar.await( - 'SELECT COALESCE(MAX(sort_order), 0) + 1 FROM mdt_bulletin_categories WHERE job = ?', - { jobName } - ) - - local id = MySQL.insert.await([[ - INSERT INTO mdt_bulletin_categories (job, value, label, icon, color, sort_order, is_default) - VALUES (?, ?, ?, ?, ?, ?, 0) - ]], { - jobName, - value, - label:sub(1, 48), - tostring(data.icon or 'label'):sub(1, 48), - tostring(data.color or '#6B7280'):sub(1, 7), - nextOrder - }) - - if not id then return { success = false, error = 'Database error' } end - return { success = true, value = value, id = id } -end) - --- ── UPDATE category ─────────────────────────────────────────── - -ps.registerCallback(resourceName .. ':server:updateBulletinCategory', function(source, data) - local src = source - if not CheckAuth(src) then return { success = false, error = 'Unauthorized' } end - if not CheckPermission(src, 'bulletin_post') then - return { success = false, error = 'No permission to manage categories' } - end - - data = data or {} - local jobName = ps.getJobName(src) - - local existing = MySQL.single.await( - 'SELECT id, is_default FROM mdt_bulletin_categories WHERE job = ? AND value = ?', - { jobName, data.value } - ) - if not existing then return { success = false, error = 'Category not found' } end - - local sets = {} - local vals = {} - - if data.label ~= nil then - local l = tostring(data.label):gsub('^%s+', ''):gsub('%s+$', '') - if l ~= '' then - sets[#sets + 1] = 'label = ?' - vals[#vals + 1] = l:sub(1, 48) - end - end - if data.icon ~= nil then - sets[#sets + 1] = 'icon = ?' - vals[#vals + 1] = tostring(data.icon):sub(1, 48) - end - if data.color ~= nil then - -- Validate hex color - local color = tostring(data.color) - if color:match('^#%x%x%x%x%x%x$') or color:match('^#%x%x%x$') then - sets[#sets + 1] = 'color = ?' - vals[#vals + 1] = color - end - end - - if #sets == 0 then return { success = false, error = 'No valid fields to update' } end - - vals[#vals + 1] = jobName - vals[#vals + 1] = data.value - - MySQL.update.await( - 'UPDATE mdt_bulletin_categories SET ' .. table.concat(sets, ', ') .. - ' WHERE job = ? AND value = ?', - vals - ) - return { success = true } -end) - --- ── REMOVE category ─────────────────────────────────────────── - -ps.registerCallback(resourceName .. ':server:removeBulletinCategory', function(source, data) - local src = source - if not CheckAuth(src) then return { success = false, error = 'Unauthorized' } end - if not CheckPermission(src, 'bulletin_post') then - return { success = false, error = 'No permission to manage categories' } - end - - -- Accept either a table { value = '...' } or a plain string (backwards compat) - local value = type(data) == 'table' and tostring(data.value or '') or tostring(data or '') - local jobName = ps.getJobName(src) - - if value == '' then return { success = false, error = 'Missing category value' } end - - local existing = MySQL.single.await( - 'SELECT id, is_default FROM mdt_bulletin_categories WHERE job = ? AND value = ?', - { jobName, value } - ) - if not existing then return { success = false, error = 'Category not found' } end - - -- Count remaining categories — keep at least 1 - local total = MySQL.scalar.await( - 'SELECT COUNT(*) FROM mdt_bulletin_categories WHERE job = ?', - { jobName } - ) - if (total or 0) <= 1 then - return { success = false, error = 'Cannot remove the last category' } - end - - -- Reassign posts in this category to 'general' (or the first remaining category) - local fallback = MySQL.single.await( - 'SELECT value FROM mdt_bulletin_categories WHERE job = ? AND value != ? ORDER BY sort_order ASC LIMIT 1', - { jobName, value } - ) - if fallback then - MySQL.update.await( - 'UPDATE mdt_bulletin_posts SET category = ? WHERE job = ? AND category = ?', - { fallback.value, jobName, value } - ) - end - - MySQL.update.await( - 'DELETE FROM mdt_bulletin_categories WHERE job = ? AND value = ?', - { jobName, value } - ) - return { success = true } -end) - --- ── REORDER categories ──────────────────────────────────────── - -ps.registerCallback(resourceName .. ':server:reorderBulletinCategories', function(source, data) - local src = source - if not CheckAuth(src) then return { success = false, error = 'Unauthorized' } end - if not CheckPermission(src, 'bulletin_post') then - return { success = false, error = 'No permission to manage categories' } - end - - local jobName = ps.getJobName(src) - - -- data may arrive as the array directly, or wrapped: { order = [...] } - local order = data - if type(data) == 'table' and data.order then - order = data.order - end - - if type(order) ~= 'table' then - return { success = false, error = 'Invalid order data' } - end - - for i, item in ipairs(order) do - local val = item.value and tostring(item.value) or nil - local sord = item.sort_order and tonumber(item.sort_order) or i - if val then - MySQL.update.await( - 'UPDATE mdt_bulletin_categories SET sort_order = ? WHERE job = ? AND value = ?', - { sord, jobName, val } - ) - end - end - - return { success = true } -end) \ No newline at end of file diff --git a/server/backend/cameras.lua b/server/backend/cameras.lua index d4616c29..74cc73db 100644 --- a/server/backend/cameras.lua +++ b/server/backend/cameras.lua @@ -2,7 +2,6 @@ local resourceName = tostring(GetCurrentResourceName()) -- Get available camera models for camera creation ps.registerCallback(resourceName .. ':server:getCameraModels', function(source) - if not CheckAuth(source) then return {} end -- Import Camera models from main cameras.lua local Camera = _G.Camera or {} local models = {} @@ -29,7 +28,6 @@ end) -- Validate camera model ps.registerCallback(resourceName .. ':server:validateCameraModel', function(source, modelKey) - if not CheckAuth(source) then return false end -- Import Camera models from main cameras.lua local Camera = _G.Camera or {} local isValid = (Camera.models or {})[modelKey] ~= nil diff --git a/server/backend/cases.lua b/server/backend/cases.lua index a69216a2..2d510a0e 100644 --- a/server/backend/cases.lua +++ b/server/backend/cases.lua @@ -549,40 +549,21 @@ ps.registerCallback(resourceName .. ':server:removeCaseAttachment', function(sou return { success = true } end) -ps.registerCallback(resourceName .. ':server:addEvidenceItem', function(source, payload) +ps.registerCallback(resourceName .. ':server:addEvidenceItem', function(source, caseId, evidence) local src = source if not CheckAuth(src) then return { success = false, error = 'Unauthorized' } end - payload = payload or {} - local caseId = tonumber(payload.caseId) - local reportId = tonumber(payload.reportId) - local evidence = payload.evidence or payload - - if not evidence or not evidence.title then - return { success = false, error = 'Invalid evidence: title is required' } - end - - if caseId then - local caseExists = MySQL.single.await('SELECT id FROM mdt_cases WHERE id = ?', { caseId }) - if not caseExists then - caseId = nil - end - end - - if reportId then - local reportExists = MySQL.single.await('SELECT id FROM mdt_reports WHERE id = ?', { reportId }) - if not reportExists then - reportId = nil - end + caseId = tonumber(caseId) + if not caseId or not evidence or not evidence.title then + return { success = false, error = 'Invalid evidence' } end local evidenceId = MySQL.insert.await([[ INSERT INTO mdt_evidence_items - (case_id, report_id, title, type, serial, notes, location, stash_id, stored, last_holder, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (case_id, title, type, serial, notes, location, stash_id, stored, last_holder, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ]], { caseId, - reportId, evidence.title, evidence.type or 'Evidence', evidence.serial or '', @@ -610,6 +591,8 @@ ps.registerCallback(resourceName .. ':server:addEvidenceItem', function(source, return { success = true, id = evidenceId } end) +-- addEvidenceImage handler is in evidence.lua (not duplicated here) + ps.registerCallback(resourceName .. ':server:removeEvidenceImage', function(source, imageId) local src = source if not CheckAuth(src) then return { success = false, error = 'Unauthorized' } end diff --git a/server/backend/citizens.lua b/server/backend/citizens.lua index 16e238b1..8cde669c 100644 --- a/server/backend/citizens.lua +++ b/server/backend/citizens.lua @@ -100,9 +100,7 @@ ps.registerCallback(resourceName .. ':server:getCitizens', function(source, page JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.gender')) AS gender, JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.birthdate')) AS dateofbirth, JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.phone')) AS phone, - JSON_UNQUOTE(JSON_EXTRACT(p.job, '$.label')) AS job, - JSON_UNQUOTE(JSON_EXTRACT(p.metadata, '$.fingerprint')) AS fingerprint, - JSON_UNQUOTE(JSON_EXTRACT(p.metadata, '$.dna')) AS dna + JSON_UNQUOTE(JSON_EXTRACT(p.job, '$.label')) AS job FROM players AS p LEFT JOIN mdt_profiles AS mp ON CONVERT(p.citizenid USING utf8mb4) COLLATE utf8mb4_general_ci = CONVERT(mp.citizenid USING utf8mb4) COLLATE utf8mb4_general_ci @@ -145,11 +143,11 @@ ps.registerCallback(resourceName .. ':server:getCitizens', function(source, page end local propRows = safeQuery( - ('SELECT owner, COUNT(*) AS cnt FROM properties WHERE owner IN (%s) GROUP BY owner'):format(inClause), + ('SELECT citizenid, COUNT(*) AS cnt FROM player_houses WHERE citizenid IN (%s) GROUP BY citizenid'):format(inClause), citizenids ) for _, row in ipairs(propRows) do - propCounts[row.owner] = tonumber(row.cnt) or 0 + propCounts[row.citizenid] = tonumber(row.cnt) or 0 end local vehRows = safeQuery( @@ -178,8 +176,6 @@ ps.registerCallback(resourceName .. ':server:getCitizens', function(source, page v.dob = v.dateofbirth v.phone = v.phone v.image = profilePics[v.citizenid] or nil - v.fingerprint = v.fingerprint or nil - v.dna = v.dna or nil v.occupations = { v.job } v.properties = propCounts[v.citizenid] or 0 v.vehicles = vehCounts[v.citizenid] or 0 @@ -220,15 +216,14 @@ ps.registerCallback(resourceName .. ':server:searchCitizens', function(source, q -- Build a complex search query that searches across multiple fields and returns same data as getCitizens local sqlQuery = [[ SELECT DISTINCT - mp.id, p.citizenid, + mp.id, + p.citizenid, JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.firstname')) AS firstname, JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.lastname')) AS lastname, JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.gender')) AS gender, JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.birthdate')) AS dateofbirth, JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.phone')) AS phone, - JSON_UNQUOTE(JSON_EXTRACT(p.job, '$.label')) AS job, - JSON_UNQUOTE(JSON_EXTRACT(p.metadata, '$.fingerprint')) AS fingerprint, - JSON_UNQUOTE(JSON_EXTRACT(p.metadata, '$.dna')) AS dna + JSON_UNQUOTE(JSON_EXTRACT(p.job, '$.label')) AS job FROM players AS p LEFT JOIN mdt_profiles AS mp ON p.citizenid COLLATE utf8mb4_general_ci = mp.citizenid COLLATE utf8mb4_general_ci WHERE @@ -237,15 +232,13 @@ ps.registerCallback(resourceName .. ':server:searchCitizens', function(source, q LOWER(CONCAT(JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.firstname')), ' ', JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.lastname')))) LIKE ? OR LOWER(p.citizenid) LIKE ? OR LOWER(JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.phone'))) LIKE ? OR - LOWER(JSON_UNQUOTE(JSON_EXTRACT(p.job, '$.label'))) LIKE ? OR - LOWER(JSON_UNQUOTE(JSON_EXTRACT(p.metadata, '$.fingerprint'))) LIKE ? OR - LOWER(JSON_UNQUOTE(JSON_EXTRACT(p.metadata, '$.dna'))) LIKE ? + LOWER(JSON_UNQUOTE(JSON_EXTRACT(p.job, '$.label'))) LIKE ? LIMIT ? ]] + local searchLimit = Config.Pagination and Config.Pagination.CitizenSearch or 20 local result = safeQuery(sqlQuery, { - searchTerm, searchTerm, searchTerm, searchTerm, - searchTerm, searchTerm, searchTerm, searchTerm + searchTerm, searchTerm, searchTerm, searchTerm, searchTerm, searchTerm, searchLimit }) if not result or #result == 0 then return {} end @@ -283,11 +276,11 @@ ps.registerCallback(resourceName .. ':server:searchCitizens', function(source, q end local propRows = safeQuery( - ('SELECT owner, COUNT(*) AS cnt FROM properties WHERE owner IN (%s) GROUP BY owner'):format(inClause), + ('SELECT citizenid, COUNT(*) AS cnt FROM player_houses WHERE citizenid IN (%s) GROUP BY citizenid'):format(inClause), citizenids ) for _, row in ipairs(propRows) do - propCounts[row.owner] = tonumber(row.cnt) or 0 + propCounts[row.citizenid] = tonumber(row.cnt) or 0 end local vehRows = safeQuery( @@ -427,7 +420,7 @@ ps.registerCallback(resourceName .. ':server:getCitizenProfile', function(source local flags = collectCitizenFlags({ citizenid }) local vehicles = MySQL.query.await('SELECT plate, vehicle FROM player_vehicles WHERE citizenid = ?', { citizenid }) or {} local vehiclesCount = #vehicles - local properties = MySQL.query.await('SELECT id, property_name, coords, keyholders FROM properties WHERE owner = ?', { citizenid }) or {} + local properties = MySQL.query.await('SELECT house FROM player_houses WHERE citizenid = ?', { citizenid }) or {} local propertiesCount = #properties local arrestsCount = MySQL.scalar.await('SELECT COUNT(*) FROM mdt_arrests WHERE citizenid = ?', { citizenid }) or 0 local activeWarrants = MySQL.query.await([[ @@ -517,25 +510,12 @@ ps.registerCallback(resourceName .. ':server:getCitizenProfile', function(source end local weapons = MySQL.query.await([[ - SELECT id, serial, scratched, owner, information, weaponClass, weaponModel, flags + SELECT id, serial, scratched, owner, information, weaponClass, weaponModel FROM mdt_weapons WHERE owner = ? ORDER BY id DESC ]], { citizenid }) or {} - local weaponsMapped = {} - for _, w in ipairs(weapons) do - weaponsMapped[#weaponsMapped + 1] = { - id = w.id, - serial = w.serial, - scratched = w.scratched, - information = w.information, - weaponClass = w.weaponClass, - weaponModel = w.weaponModel, - flags = w.flags and json.decode(w.flags) or {}, - } - end - local linkedReports = {} if #involvedReportIds > 0 then local placeholders = (string.rep('?,', #involvedReportIds)):sub(1, -2) @@ -602,7 +582,7 @@ ps.registerCallback(resourceName .. ':server:getCitizenProfile', function(source activeWarrants = activeWarrants, activeBolos = activeBoloDetails, evidence = evidence, - weapons = weaponsMapped, + weapons = weapons, linkedReports = linkedReports, ownedVehicles = vehicles, propertiesList = properties, @@ -1075,14 +1055,9 @@ ps.registerCallback(resourceName .. ':server:getMyProfile', function(source) if rid and not reportIdSet[rid] then reportIdSet[rid] = true end end - local reportIds = {} - for rid in pairs(reportIdSet) do reportIds[#reportIds + 1] = rid end - if #reportIds > 0 then - local placeholders = string.rep('?,', #reportIds - 1) .. '?' - local lrOk, reports = pcall(MySQL.query.await, - 'SELECT id, title, type FROM mdt_reports WHERE id IN (' .. placeholders .. ')', - reportIds) - for _, report in ipairs(lrOk and reports or {}) do + for rid, _ in pairs(reportIdSet) do + local rOk, report = pcall(MySQL.single.await, 'SELECT id, title, type FROM mdt_reports WHERE id = ?', { rid }) + if rOk and report then linkedReports[#linkedReports + 1] = { id = report.id, title = report.title, type = report.type } end end @@ -1133,114 +1108,3 @@ ps.registerCallback(resourceName .. ':server:getMyProfile', function(source) } } end) - -ps.registerCallback(resourceName .. ':server:getProperty', function(source, propertyId) - local src = source - if not CheckAuth(src) then return { success = false, message = 'Unauthorized' } end - - if not propertyId then - return { success = false, message = 'Missing property id' } - end - - local propRow = MySQL.single.await([[ - SELECT id, property_name, coords, owner, keyholders - FROM properties - WHERE id = ? - LIMIT 1 - ]], { propertyId }) - - if not propRow then - return { success = false, message = 'Property not found' } - end - - -- Decode coords JSON → table - local coords = nil - if propRow.coords and propRow.coords ~= '' then - local ok, decoded = pcall(json.decode, propRow.coords) - if ok and decoded then - coords = decoded - end - end - - -- Resolve owner citizenid → full name - local ownerName = nil - if propRow.owner and propRow.owner ~= '' then - local ownerRow = MySQL.single.await([[ - SELECT JSON_UNQUOTE(JSON_EXTRACT(charinfo, '$.firstname')) AS firstname, - JSON_UNQUOTE(JSON_EXTRACT(charinfo, '$.lastname')) AS lastname - FROM players - WHERE citizenid = ? - LIMIT 1 - ]], { propRow.owner }) - if ownerRow then - ownerName = (ownerRow.firstname or '') .. ' ' .. (ownerRow.lastname or '') - ownerName = ownerName:match('^%s*(.-)%s*$') -- trim - end - end - - -- Decode keyholders JSON → array of citizenids - -- The column stores either a JSON array of citizenids (["CID1","CID2",...]) - -- or a JSON object keyed by citizenid — we handle both. - local keyholderList = {} - if propRow.keyholders and propRow.keyholders ~= '' and propRow.keyholders ~= '{}' and propRow.keyholders ~= '[]' then - local ok, decoded = pcall(json.decode, propRow.keyholders) - if ok and decoded then - -- Array form: ["CID1", "CID2"] - if decoded[1] ~= nil then - for _, cid in ipairs(decoded) do - keyholderList[#keyholderList + 1] = tostring(cid) - end - else - -- Object/map form: { CID1 = true, CID2 = 1, ... } - for cid, _ in pairs(decoded) do - keyholderList[#keyholderList + 1] = tostring(cid) - end - end - end - end - - -- Batch-resolve keyholder names - local keyholders = {} - if #keyholderList > 0 then - local placeholders = {} - for i = 1, #keyholderList do placeholders[i] = '?' end - local khRows = MySQL.query.await(([[ - SELECT citizenid, - JSON_UNQUOTE(JSON_EXTRACT(charinfo, '$.firstname')) AS firstname, - JSON_UNQUOTE(JSON_EXTRACT(charinfo, '$.lastname')) AS lastname - FROM players - WHERE citizenid IN (%s) - ]]):format(table.concat(placeholders, ',')), keyholderList) - - -- Build a lookup map for fast access - local nameMap = {} - for _, row in ipairs(khRows or {}) do - local full = ((row.firstname or '') .. ' ' .. (row.lastname or '')):match('^%s*(.-)%s*$') - nameMap[row.citizenid] = full ~= '' and full or nil - end - - -- Preserve original keyholder order - for _, cid in ipairs(keyholderList) do - -- Skip if the keyholder is the owner (already shown as owner) - if cid ~= propRow.owner then - keyholders[#keyholders + 1] = { - citizenid = cid, - name = nameMap[cid] or 'Unknown', - } - end - end - end - - - return { - success = true, - property = { - property_name = propRow.property_name, - coords = coords, - streetName = propRow.streetName, - owner = propRow.owner or nil, - ownerName = ownerName, - keyholders = keyholders, - } - } -end) \ No newline at end of file diff --git a/server/backend/collab.lua b/server/backend/collab.lua index 669fca3a..10aff30d 100644 --- a/server/backend/collab.lua +++ b/server/backend/collab.lua @@ -119,7 +119,7 @@ ps.registerCallback(resourceName .. ':server:joinReportSession', function(source myName = editorName, myCitizenId = citizenId, editors = getEditorsList(session, src), - yjsUpdates = session.yjsUpdates or {}, + yjsState = session.yjsState, lastStructuredData = session.lastStructuredData, } end) @@ -167,6 +167,11 @@ AddEventHandler(resourceName .. ':server:collabSyncYjs', function(reportId, upda -- Store for late joiners if not session.yjsUpdates then session.yjsUpdates = {} end session.yjsUpdates[#session.yjsUpdates + 1] = update + session.yjsState = update + if #session.yjsUpdates > 100 then + session.yjsState = update + session.yjsUpdates = { update } + end -- Queue for batched broadcast if not yjsPendingBroadcasts[reportId] then @@ -220,24 +225,6 @@ AddEventHandler(resourceName .. ':server:collabSyncData', function(reportId, dat }, src) end) -RegisterNetEvent(resourceName .. ':server:collabSyncAwareness') -AddEventHandler(resourceName .. ':server:collabSyncAwareness', function(reportId, update) - local src = source - if not CheckAuth(src) then return end - reportId = tostring(reportId) - local session = activeReportSessions[reportId] - if not session or not session.editors[src] then return end - - for editorSrc, _ in pairs(session.editors) do - if editorSrc ~= src then - TriggerClientEvent(resourceName .. ':client:awarenessBatch', editorSrc, { - reportId = reportId, - updates = { update }, - }) - end - end -end) - -- Cleanup on player disconnect AddEventHandler('playerDropped', function() local src = source diff --git a/server/backend/dashboard.lua b/server/backend/dashboard.lua index c9daaccd..17a1f6a8 100644 --- a/server/backend/dashboard.lua +++ b/server/backend/dashboard.lua @@ -1,3 +1,4 @@ + local resourceName = tostring(GetCurrentResourceName()) local function getEffectiveJobType(src) @@ -15,10 +16,9 @@ end ps.registerCallback(resourceName .. ':server:getJobData', function(source) local src = source assert(src, 'Player ID cannot be nil') - if not CheckAuth(src) then return {} end local response = { - rank = ps.getJobGradeName(src) or 'Officer', - payRate = '$' .. (ps.getJobGradePay(src) or 300) .. '/hr', + rank = ps.getJobGradeName(src) or "Officer", + payRate = "$" .. (ps.getJobGradePay(src) or 300) .. "/hr", } return response end) @@ -26,7 +26,6 @@ end) ps.registerCallback(resourceName .. ':server:getReportStatistics', function(source) local src = source assert(src, 'Player ID cannot be nil') - if not CheckAuth(src) then return {} end return Cache.getOrSet('dashboard:reportStats', Config.CacheTTL and Config.CacheTTL.ReportStats or 30, function() local response = MySQL.query.await([[ @@ -37,17 +36,28 @@ ps.registerCallback(resourceName .. ':server:getReportStatistics', function(sour ]], {}) local row = response and response[1] or { totalThisWeek = 0, totalLastWeek = 0 } - return { - totalThisWeek = tonumber(row.totalThisWeek) or 0, - changeFromLastWeek = (tonumber(row.totalThisWeek) or 0) - (tonumber(row.totalLastWeek) or 0), + local reportStatistics = { + totalThisWeek = tonumber(row.totalThisWeek) or 0, + changeFromLastWeek = (tonumber(row.totalThisWeek) or 0) - (tonumber(row.totalLastWeek) or 0) } + return reportStatistics end) end) +local function parseDateOnly(value) + if not value then + return nil + end + local year, month, day = tostring(value):match('^(%d%d%d%d)%-(%d%d)%-(%d%d)$') + if not year then + return nil + end + return os.time({ year = tonumber(year), month = tonumber(month), day = tonumber(day), hour = 0 }) +end + ps.registerCallback(resourceName .. ':server:getTimeStatistics', function(source) local src = source assert(src, 'Player ID cannot be nil') - if not CheckAuth(src) then return {} end local citizenid = ps.getIdentifier(src) if not citizenid then return {} end @@ -68,14 +78,16 @@ ps.registerCallback(resourceName .. ':server:getTimeStatistics', function(source local result = {} for i = 6, 0, -1 do - local dayTs = os.time() - (i * 24 * 60 * 60) + local dayTs = os.time() - (i * 24 * 60 * 60) local dayKey = os.date('%Y-%m-%d', dayTs) - local label = os.date('%a', dayTs) + local label = os.date('%a', dayTs) + local seconds = secondsByDay[dayKey] or 0 result[#result + 1] = { - day = label, - hours = math.floor(((secondsByDay[dayKey] or 0) / 3600) * 10) / 10, + day = label, + hours = math.floor((seconds / 3600) * 10) / 10 } end + return result end) @@ -129,17 +141,26 @@ ps.registerCallback(resourceName .. ':server:deleteBulletin', function(source, p end) ps.registerCallback(resourceName .. ':server:getRecentReports', function(source, page, limit) - local src = source + local src = source assert(src, 'Player ID cannot be nil') - if not CheckAuth(src) then return {} end - local pageNumber = math.max(1, tonumber(page) or 1) - local pageSize = math.min(50, math.max(1, tonumber(limit) or 10)) + local pageNumber = tonumber(page) or 1 + if pageNumber < 1 then + pageNumber = 1 + end + + local pageSize = tonumber(limit) or 10 + if pageSize < 1 then + pageSize = 10 + end + if pageSize > 50 then + pageSize = 50 + end local identifier = ps.getIdentifier(src) - local job = ps.getJobName(src) - local jobType = getEffectiveJobType(src) - local offset = (pageNumber - 1) * pageSize + local job = ps.getJobName(src) + local jobType = getEffectiveJobType(src) + local offset = (pageNumber - 1) * pageSize local rows = MySQL.query.await([[ SELECT mr.id, mr.title, mr.type, mr.contentplaintext, mr.author, mr.authorplaintext, mr.datecreated, mr.dateupdated FROM mdt_reports mr @@ -151,8 +172,8 @@ ps.registerCallback(resourceName .. ':server:getRecentReports', function(source, )) OR (mrr.reportid IS NULL AND (? = 'leo' OR ? = 'ems')) OR (mrr.type = 'citizenid' AND mrr.identifier = ?) - OR (mrr.type = 'job' AND mrr.identifier = ?) - OR (mrr.type = 'jobtype' AND mrr.identifier = ?) + OR (mrr.type = 'job' AND mrr.identifier = ?) + OR (mrr.type = 'jobtype' AND mrr.identifier = ?) ) GROUP BY mr.id ORDER BY mr.datecreated DESC @@ -169,14 +190,15 @@ ps.registerCallback(resourceName .. ':server:getActiveBolos', function(source) local BOLOS = MySQL.query.await('SELECT * FROM mdt_bolos WHERE status = ? ORDER BY id DESC', { 'active' }) local result = {} for _, v in pairs(BOLOS or {}) do - result[#result + 1] = { - id = v.id, + local formattedBolo = { + id = v.id, reportId = v.reportId and tostring(v.reportId) or 'N/A', - name = v.subject_name or ps.getPlayerNameByIdentifier(v.subject_id) or 'Unknown', - type = v.type, - notes = v.notes or '', - status = v.status, + name = v.subject_name or ps.getPlayerNameByIdentifier(v.subject_id) or 'Unknown', + type = v.type, + notes = v.notes or '', + status = v.status, } + table.insert(result, formattedBolo) end ps.debug('Fetched ' .. #result .. ' active BOLOs from database for source ' .. src, result) return result @@ -185,33 +207,37 @@ end) ps.registerCallback(resourceName .. ':server:getActiveUnits', function(source) local src = source assert(src, 'Player ID cannot be nil') - if not CheckAuth(src) then return { count = 0 } end return Cache.getOrSet('dashboard:activeUnits', Config.CacheTTL and Config.CacheTTL.ActiveUnits or 10, function() return { count = ps.getJobTypeCount('leo') } end) end) +-- Sanitize dispatch data for safe serialization through ps.callback +-- ps-dispatch objects contain vectors/coords that msgpack can't serialize local function sanitizeDispatch(call) if not call or type(call) ~= 'table' then return nil end + local sanitized = { - id = call.id, - message = call.message or call.dispatchMessage or '', - code = call.code or call.dispatchCode or '', - street = call.street or '', - priority = call.priority or 0, - time = call.time or 0, - gender = call.gender, - plate = call.plate, - color = call.color, - model = call.model, - weapon = call.weapon, - heading = call.heading, - speed = call.speed, - callSign = call.callSign, + id = call.id, + message = call.message or call.dispatchMessage or '', + code = call.code or call.dispatchCode or '', + street = call.street or '', + priority = call.priority or 0, + time = call.time or 0, + gender = call.gender, + plate = call.plate, + color = call.color, + model = call.model, + weapon = call.weapon, + heading = call.heading, + speed = call.speed, + callSign = call.callSign, description = call.description, - camId = call.camId, - firstColor = call.firstColor, + camId = call.camId, + firstColor = call.firstColor, } + + -- Sanitize coords (vectors can't serialize) if call.coords then if type(call.coords) == 'vector3' or type(call.coords) == 'vector4' then sanitized.coords = { x = call.coords.x, y = call.coords.y, z = call.coords.z } @@ -219,32 +245,36 @@ local function sanitizeDispatch(call) sanitized.coords = { x = call.coords.x or call.coords[1], y = call.coords.y or call.coords[2], z = call.coords.z or call.coords[3] } end end + + -- Sanitize units array sanitized.units = {} if call.units and type(call.units) == 'table' then for _, unit in pairs(call.units) do if type(unit) == 'table' then sanitized.units[#sanitized.units + 1] = { citizenid = unit.citizenid, - charinfo = unit.charinfo, - job = unit.job, - metadata = unit.metadata and { callsign = unit.metadata.callsign } or nil, + charinfo = unit.charinfo, + job = unit.job, + metadata = unit.metadata and { callsign = unit.metadata.callsign } or nil, } end end end + + -- Sanitize jobs array if call.jobs and type(call.jobs) == 'table' then sanitized.jobs = {} for _, job in ipairs(call.jobs) do sanitized.jobs[#sanitized.jobs + 1] = job end end + return sanitized end ps.registerCallback(resourceName .. ':server:getRecentDispatches', function(source) local src = source assert(src, 'Player ID cannot be nil') - if not CheckAuth(src) then return {} end local dispatchResource = Config and Config.Dispatch and Config.Dispatch.Resource or 'ps-dispatch' local ok, recentDispatches = pcall(function() return exports[dispatchResource] and exports[dispatchResource]:GetDispatchCalls() or {} @@ -252,6 +282,7 @@ ps.registerCallback(resourceName .. ':server:getRecentDispatches', function(sour if not ok then return {} end recentDispatches = recentDispatches or {} + -- Filter by job if configured local dispatches = recentDispatches if Config and Config.Dispatch and Config.Dispatch.FilterByJob == true then local jobName = ps.getJobName(src) @@ -262,23 +293,31 @@ ps.registerCallback(resourceName .. ':server:getRecentDispatches', function(sour if not call.jobs or #call.jobs == 0 then filtered[#filtered + 1] = call else + local matched = false for _, job in ipairs(call.jobs) do if job == jobName or job == jobType then - filtered[#filtered + 1] = call + matched = true break end end + if matched then + filtered[#filtered + 1] = call + end end end dispatches = filtered end end + -- Sanitize for serialization local result = {} for _, call in ipairs(dispatches) do local sanitized = sanitizeDispatch(call) - if sanitized then result[#result + 1] = sanitized end + if sanitized then + result[#result + 1] = sanitized + end end + return result end) @@ -287,22 +326,33 @@ ps.registerCallback(resourceName .. ':server:getUsageMetrics', function(source) if not CheckAuth(src) then return {} end return Cache.getOrSet('dashboard:usageMetrics', Config.CacheTTL and Config.CacheTTL.UsageMetrics or 60, function() + -- Use separate queries with pcall to handle missing tables gracefully local function safeCount(query, params) local ok, result = pcall(MySQL.scalar.await, query, params or {}) - return ok and (tonumber(result) or 0) or 0 + if ok then return tonumber(result) or 0 end + return 0 end + + local totalReports = safeCount('SELECT COUNT(*) FROM mdt_reports') + local reportsLast7 = safeCount('SELECT COUNT(*) FROM mdt_reports WHERE datecreated >= NOW() - INTERVAL 7 DAY') + local reportsLast30 = safeCount('SELECT COUNT(*) FROM mdt_reports WHERE datecreated >= NOW() - INTERVAL 30 DAY') + local totalArrests = safeCount('SELECT COUNT(*) FROM mdt_arrests') + local arrestsLast7 = safeCount('SELECT COUNT(*) FROM mdt_arrests WHERE created_at >= NOW() - INTERVAL 7 DAY') + local arrestsLast30 = safeCount('SELECT COUNT(*) FROM mdt_arrests WHERE created_at >= NOW() - INTERVAL 30 DAY') + local activeWarrants = safeCount('SELECT COUNT(*) FROM mdt_reports_warrants WHERE expirydate >= NOW()') + return { totals = { - reports = safeCount('SELECT COUNT(*) FROM mdt_reports'), - arrests = safeCount('SELECT COUNT(*) FROM mdt_arrests'), - activeWarrants = safeCount('SELECT COUNT(*) FROM mdt_reports_warrants WHERE expirydate >= NOW()'), + reports = totalReports, + arrests = totalArrests, + activeWarrants = activeWarrants, }, windows = { - reportsLast7 = safeCount('SELECT COUNT(*) FROM mdt_reports WHERE datecreated >= NOW() - INTERVAL 7 DAY'), - reportsLast30 = safeCount('SELECT COUNT(*) FROM mdt_reports WHERE datecreated >= NOW() - INTERVAL 30 DAY'), - arrestsLast7 = safeCount('SELECT COUNT(*) FROM mdt_arrests WHERE created_at >= NOW() - INTERVAL 7 DAY'), - arrestsLast30 = safeCount('SELECT COUNT(*) FROM mdt_arrests WHERE created_at >= NOW() - INTERVAL 30 DAY'), - }, + reportsLast7 = reportsLast7, + reportsLast30 = reportsLast30, + arrestsLast7 = arrestsLast7, + arrestsLast30 = arrestsLast30, + } } end) -end) \ No newline at end of file +end) diff --git a/server/backend/evidence.lua b/server/backend/evidence.lua index 858c8b6c..29b9230d 100644 --- a/server/backend/evidence.lua +++ b/server/backend/evidence.lua @@ -42,37 +42,6 @@ ps.registerCallback(resourceName .. ':server:getEvidenceItems', function(source, LIMIT ? OFFSET ? ]]):format(whereClause), listValues) - if evidence and #evidence > 0 then - local ids = {} - local idLookup = {} - for _, item in ipairs(evidence) do - ids[#ids + 1] = item.id - idLookup[item.id] = item - item.images = {} - end - - local placeholders = string.rep('?,', #ids - 1) .. '?' - local images = MySQL.query.await(([[ - SELECT id, evidence_id, url, label, uploaded_by, uploaded_at - FROM mdt_evidence_images - WHERE evidence_id IN (%s) - ORDER BY uploaded_at DESC - ]]):format(placeholders), ids) - - for _, img in ipairs(images or {}) do - local parent = idLookup[img.evidence_id] - if parent then - parent.images[#parent.images + 1] = { - id = img.id, - url = img.url, - label = img.label, - uploaded_by = img.uploaded_by, - uploaded_at = img.uploaded_at - } - end - end - end - return { success = true, data = { @@ -114,37 +83,6 @@ ps.registerCallback(resourceName .. ':server:searchEvidenceItems', function(sour LIMIT ? OFFSET ? ]], { likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, limit, offset }) - if evidence and #evidence > 0 then - local ids = {} - local idLookup = {} - for _, item in ipairs(evidence) do - ids[#ids + 1] = item.id - idLookup[item.id] = item - item.images = {} - end - - local placeholders = string.rep('?,', #ids - 1) .. '?' - local images = MySQL.query.await(([[ - SELECT id, evidence_id, url, label, uploaded_by, uploaded_at - FROM mdt_evidence_images - WHERE evidence_id IN (%s) - ORDER BY uploaded_at DESC - ]]):format(placeholders), ids) - - for _, img in ipairs(images or {}) do - local parent = idLookup[img.evidence_id] - if parent then - parent.images[#parent.images + 1] = { - id = img.id, - url = img.url, - label = img.label, - uploaded_by = img.uploaded_by, - uploaded_at = img.uploaded_at - } - end - end - end - return { success = true, data = { @@ -161,36 +99,14 @@ ps.registerCallback(resourceName .. ':server:addEvidenceItem', function(source, if not CheckAuth(src) then return { success = false, error = 'Unauthorized' } end payload = payload or {} + local caseId = tonumber(payload.caseId) + local reportId = tonumber(payload.reportId) local evidence = payload.evidence or payload if not evidence or not evidence.title then return { success = false, error = 'Invalid evidence: title is required' } end - local caseId = nil - if payload.caseId and tostring(payload.caseId) ~= '' then - local n = tonumber(payload.caseId) - if n then - local row = MySQL.single.await('SELECT id FROM mdt_cases WHERE id = ?', { n }) - if not row then - return { success = false, error = 'Case #' .. tostring(n) .. ' doesnt exist' } - end - caseId = n - end - end - - local reportId = nil - if payload.reportId and tostring(payload.reportId) ~= '' then - local n = tonumber(payload.reportId) - if n then - local row = MySQL.single.await('SELECT id FROM mdt_reports WHERE id = ?', { n }) - if not row then - return { success = false, error = 'Report #' .. tostring(n) .. ' doesnt exist' } - end - reportId = n - end - end - local evidenceId = MySQL.insert.await([[ INSERT INTO mdt_evidence_items (case_id, report_id, title, type, serial, notes, location, stash_id, stored, last_holder, created_by) @@ -387,9 +303,41 @@ ps.registerCallback(resourceName .. ':server:addEvidenceImage', function(source, return { success = false, error = 'Invalid image' } end - local url = image.url or image.data or '' + local url = image.url local label = image.label or '' + -- Upload via FiveManage if base64 data is provided + if image.data and image.filename then + local contentType = image.contentType or '' + if Config.Uploads and Config.Uploads.AllowedEvidenceImageTypes then + local allowed = false + for _, mime in ipairs(Config.Uploads.AllowedEvidenceImageTypes) do + if mime == contentType then + allowed = true + break + end + end + if not allowed then + return { success = false, error = 'Unsupported image type' } + end + end + + if not FiveManageUpload then + return { success = false, error = 'FiveManage upload not available' } + end + + local dataUri = image.data + if type(dataUri) ~= 'string' or dataUri == '' then + return { success = false, error = 'Invalid image data' } + end + + local uploadedUrl, uploadError = FiveManageUpload(dataUri, image.filename) + if not uploadedUrl then + return { success = false, error = 'Upload failed: ' .. (uploadError or 'Unknown error') } + end + url = uploadedUrl + end + if not url or url == '' then return { success = false, error = 'Missing image URL' } end @@ -461,6 +409,7 @@ ps.registerCallback(resourceName .. ':server:linkEvidenceToCase', function(sourc evidenceId = tonumber(evidenceId) reportId = tonumber(reportId) + -- Support case number format like "CASE-2026-003" by looking up the numeric ID local numericCaseId = tonumber(caseId) if not numericCaseId and type(caseId) == 'string' and caseId ~= '' then local row = MySQL.single.await('SELECT id FROM mdt_cases WHERE case_number = ? LIMIT 1', { caseId }) @@ -474,18 +423,9 @@ ps.registerCallback(resourceName .. ':server:linkEvidenceToCase', function(sourc return { success = false, error = 'Invalid evidence or case' } end - local caseExists = MySQL.single.await('SELECT id FROM mdt_cases WHERE id = ?', { caseId }) - if not caseExists then - return { success = false, error = 'Case #' .. tostring(caseId) .. ' does not exist' } - end - MySQL.update.await('UPDATE mdt_evidence_items SET case_id = ? WHERE id = ?', { caseId, evidenceId }) - if reportId then - local reportExists = MySQL.single.await('SELECT id FROM mdt_reports WHERE id = ?', { reportId }) - if reportExists then - linkReportToCase(reportId, caseId, ps.getIdentifier(src)) - end + linkReportToCase(reportId, caseId, ps.getIdentifier(src)) end if ps.auditLog then @@ -509,11 +449,6 @@ ps.registerCallback(resourceName .. ':server:linkEvidenceToReport', function(sou return { success = false, error = 'Invalid evidence or report' } end - local reportExists = MySQL.single.await('SELECT id FROM mdt_reports WHERE id = ?', { reportId }) - if not reportExists then - return { success = false, error = 'Report #' .. tostring(reportId) .. ' does not exist' } - end - MySQL.update.await('UPDATE mdt_evidence_items SET report_id = ? WHERE id = ?', { reportId, evidenceId }) if ps.auditLog then @@ -568,23 +503,11 @@ end) RegisterNetEvent(resourceName .. ':server:openEvidenceStash', function(stashId) local src = source if not CheckAuth(src) then return end - if not stashId or stashId == '' then return end - - -- qb-inventory - if GetResourceState('qb-inventory') == 'started' then - exports['qb-inventory']:OpenInventory(src, stashId, { - maxweight = 4000000, - slots = 500, - }) - return - end - -- ox_inventory with forceOpenInventory - if GetResourceState('ox_inventory') == 'started' then - - exports.ox_inventory:RegisterStash(stashId, stashId , 500 , 4000000) + if not stashId or stashId == '' then return end - exports.ox_inventory:forceOpenInventory(src, 'stash', stashId) - return - end -end) \ No newline at end of file + exports['qb-inventory']:OpenInventory(src, stashId, { + maxweight = 4000000, + slots = 500, + }) +end) diff --git a/server/backend/management.lua b/server/backend/management.lua index 2b2c0da9..458d2f47 100644 --- a/server/backend/management.lua +++ b/server/backend/management.lua @@ -60,13 +60,13 @@ local function getAllPermissions() 'citizens_search', 'citizens_edit_licenses', 'bolos_view', 'bolos_create', 'vehicles_search', 'vehicles_edit_dmv', - 'weapons_search', 'weapons_add', + 'weapons_search', 'cases_view', 'cases_create', 'cases_edit', 'cases_delete', 'evidence_view', 'evidence_create', 'evidence_transfer', 'evidence_upload', 'reports_view', 'reports_create', 'reports_delete', 'warrants_view', 'warrants_issue', 'warrants_close', 'charges_view', 'charges_edit', - 'map_patrols_view', 'map_patrols_manage', 'map_patrols_edit', 'dispatch_attach', 'dispatch_route', + 'dispatch_attach', 'dispatch_route', 'cameras_view', 'bodycams_view', 'roster_manage_certifications', 'roster_manage_officers', 'management_permissions', 'management_bulletins', 'management_activity', diff --git a/server/backend/officers.lua b/server/backend/officers.lua index 7f247aa7..60bb9d60 100644 --- a/server/backend/officers.lua +++ b/server/backend/officers.lua @@ -1,7 +1,7 @@ local resourceName = tostring(GetCurrentResourceName()) local ok, QBCore = pcall(function() return exports['qb-core']:GetCoreObject() end) if not ok then QBCore = nil end - + -- Get player source ID by citizenId ps.registerCallback(resourceName .. ':server:GetPlayerSourceId', function(source, targetCitizenId) if not targetCitizenId then return nil end @@ -12,90 +12,79 @@ ps.registerCallback(resourceName .. ':server:GetPlayerSourceId', function(source end return targetPlayer.source or targetPlayer.PlayerData.source end) - + -- Set Callsign ps.registerCallback(resourceName .. ':server:setCallsign', function(source, payload) local src = source if not CheckAuth(src) then return { success = false, message = 'Unauthorized' } end + payload = payload or {} local cid = payload.citizenid or payload.cid local newCallsign = payload.callsign or payload.newcallsign - + if not cid or not newCallsign then return { success = false, message = 'Missing citizen ID or callsign' } end + if not QBCore then return { success = false, message = 'Core framework not available' } end local Player = QBCore.Functions.GetPlayerByCitizenId(cid) if Player then Player.Functions.SetMetaData('callsign', newCallsign) TriggerClientEvent(resourceName .. ':client:updateCallsign', Player.PlayerData.source, newCallsign) - + MySQL.update.await('UPDATE mdt_profiles SET callsign = ? WHERE citizenid = ?', { newCallsign, cid }) - + if ps.auditLog then ps.auditLog(src, 'callsign_changed', 'officer', cid, { callsign = newCallsign }) end - + return { success = true, message = 'Callsign updated to ' .. newCallsign } end + return { success = false, message = 'Player must be online to update callsign' } end) - -ps.registerCallback(resourceName .. ':server:getCallsign', function(source, payload) - if not CheckAuth(source) then return { callsign = '' } end - local cid = payload.citizenid - if not cid then return { callsign = '' } end - - if not QBCore then return { success = false, message = 'Core framework not available' } end - local Player = QBCore.Functions.GetPlayerByCitizenId(cid) - if Player then - return { callsign = tostring(Player.PlayerData.metadata.callsign or '') } - end - local row = MySQL.single.await('SELECT callsign FROM mdt_profiles WHERE citizenid = ?', { cid }) - return { callsign = tostring(row and row.callsign or '') } -end) - + -- Set Radio Frequency ps.registerCallback(resourceName .. ':server:setRadio', function(source, payload) local src = source if not CheckAuth(src) then return { success = false, message = 'Unauthorized' } end - + payload = payload or {} local cid = payload.citizenid or payload.cid local newRadio = payload.radio or payload.newradio - + if not cid or not newRadio then return { success = false, message = 'Missing citizen ID or radio frequency' } end - + if not QBCore then return { success = false, message = 'Core framework not available' } end local targetPlayer = QBCore.Functions.GetPlayerByCitizenId(cid) if not targetPlayer then return { success = false, message = 'Officer must be online' } end - + local targetSource = targetPlayer.PlayerData.source - + local radio = targetPlayer.Functions.GetItemByName('radio') if not radio then return { success = false, message = targetPlayer.PlayerData.charinfo.firstname .. ' does not have a radio!' } end - + TriggerClientEvent(resourceName .. ':client:setRadio', targetSource, newRadio) return { success = true, message = 'Radio set to ' .. newRadio } end) - + -- Get Unit Location (GPS to officer) ps.registerCallback(resourceName .. ':server:getUnitLocation', function(source, cid) if not CheckAuth(source) then return {} end if not cid then return {} end - + if not QBCore then return {} end local Player = QBCore.Functions.GetPlayerByCitizenId(cid) if Player then local coords = GetEntityCoords(GetPlayerPed(Player.PlayerData.source)) return { x = coords.x, y = coords.y, z = coords.z } end - + return {} -end) \ No newline at end of file +end) diff --git a/server/backend/reports.lua b/server/backend/reports.lua index c44a0e7b..13b66572 100644 --- a/server/backend/reports.lua +++ b/server/backend/reports.lua @@ -91,51 +91,36 @@ local function normalizeDateFilter(value) end local function buildReportFilterClause(filters) - local clauses = {} - local values = {} - if not filters then - return '', values - end - - local function hasValue(value) - if value == nil then return false end - if json and value == json.null then return false end - if type(value) == 'string' then return value:gsub('%s+', '') ~= '' end - return true - end + local clauses = {} + local values = {} + if not filters then + return '', values + end - if hasValue(filters.search) then - local likeQuery = '%' .. tostring(filters.search) .. '%' - clauses[#clauses + 1] = [[ - ( - mr.title LIKE ? - OR CAST(mr.id AS CHAR) LIKE ? - OR mr.authorplaintext LIKE ? - OR mr.type LIKE ? - OR EXISTS ( - SELECT 1 FROM mdt_reports_tags mrt2 - WHERE mrt2.reportid = mr.id AND mrt2.tag LIKE ? - ) - ) - ]] - values[#values + 1] = likeQuery - values[#values + 1] = likeQuery - values[#values + 1] = likeQuery - values[#values + 1] = likeQuery - values[#values + 1] = likeQuery - end + local function hasValue(value) + if value == nil then + return false + end + if json and value == json.null then + return false + end + if type(value) == 'string' then + return value:gsub('%s+', '') ~= '' + end + return true + end - if hasValue(filters.type) then - clauses[#clauses + 1] = 'mr.type = ?' - values[#values + 1] = filters.type - end + if hasValue(filters.type) then + clauses[#clauses + 1] = 'mr.type = ?' + values[#values + 1] = filters.type + end - if hasValue(filters.author) then - local likeQuery = '%' .. tostring(filters.author) .. '%' - clauses[#clauses + 1] = '(mr.authorplaintext LIKE ? OR mr.author LIKE ?)' - values[#values + 1] = likeQuery - values[#values + 1] = likeQuery - end + if hasValue(filters.author) then + local likeQuery = '%' .. tostring(filters.author) .. '%' + clauses[#clauses + 1] = '(mr.authorplaintext LIKE ? OR mr.author LIKE ?)' + values[#values + 1] = likeQuery + values[#values + 1] = likeQuery + end local startDate = normalizeDateFilter(filters.startDate) if startDate then @@ -219,67 +204,54 @@ end ps.registerCallback(resourceName .. ':server:getReports', function(source, page, filters) - local src = source - if not CheckAuth(src) then return end + local src = source + if not CheckAuth(src) then return end local identifier = ps.getIdentifier(src) local job = ps.getJobName(src) local jobType = getEffectiveJobType(src) - local pageNumber = tonumber(page) or 1 - pageNumber = math.max(1, pageNumber) - local limit = math.min(tonumber(filters and filters.limit) or 20, 100) - local offset = (pageNumber - 1) * limit - - local filterClause, filterValues = buildReportFilterClause(filters) - filterClause = filterClause or '' - - local accessClause = buildReportAccessClause() - local baseParams = { jobType, jobType, jobType, identifier, job, jobType } - - local queryParams = {} - for _, v in ipairs(baseParams) do queryParams[#queryParams + 1] = v end - for _, v in ipairs(filterValues or {}) do queryParams[#queryParams + 1] = v end - - local countQuery = ([[ - SELECT COUNT(DISTINCT mr.id) AS total - FROM mdt_reports AS mr - LEFT JOIN mdt_reports_restrictions AS mrr ON mr.id = mrr.reportid - WHERE %s%s - ]]):format(accessClause, filterClause) - - local countRow = MySQL.single.await(countQuery, queryParams) - local total = tonumber(countRow and countRow.total) or 0 - - local reportsQuery = ([[ - SELECT - mr.id, - mr.id as reportId, - mr.title, - mr.type, - mr.contentyjs, - mr.contentplaintext, - mr.author, - mr.authorplaintext, - mr.datecreated, - mr.dateupdated, - (SELECT mrt.tag FROM mdt_reports_tags mrt WHERE mrt.reportid = mr.id LIMIT 1) as tag, - (SELECT COUNT(*) FROM mdt_reports_tags mrt WHERE mrt.reportid = mr.id) as tagCount - FROM mdt_reports AS mr - LEFT JOIN mdt_reports_restrictions AS mrr ON mr.id = mrr.reportid - WHERE %s%s - GROUP BY mr.id - ORDER BY mr.id DESC - LIMIT %d OFFSET %d - ]]):format(accessClause, filterClause, limit, offset) - - local reportsParams = {} - for _, v in ipairs(baseParams) do reportsParams[#reportsParams + 1] = v end - for _, v in ipairs(filterValues or {}) do reportsParams[#reportsParams + 1] = v end - - local reports = MySQL.query.await(reportsQuery, reportsParams) - - return { reports = reports or {}, total = total } + local pageNumber = tonumber(page) or 1 + pageNumber = math.max(1, pageNumber) + local limit = 20 + local offset = (pageNumber - 1) * limit + + local filterClause, filterValues = buildReportFilterClause(filters) + filterClause = filterClause or '' + + local reportsQuery = ([[ + SELECT + mr.id, + mr.id as reportId, + mr.title, + mr.type, + mr.contentyjs, + mr.contentplaintext, + mr.author, + mr.authorplaintext, + mr.datecreated, + mr.dateupdated, + (SELECT mrt.tag FROM mdt_reports_tags mrt WHERE mrt.reportid = mr.id LIMIT 1) as tag, + (SELECT COUNT(*) FROM mdt_reports_tags mrt WHERE mrt.reportid = mr.id) as tagCount + FROM + mdt_reports AS mr + LEFT JOIN + mdt_reports_restrictions AS mrr ON mr.id = mrr.reportid + WHERE + %s%s + GROUP BY + mr.id + ORDER BY + mr.datecreated DESC + LIMIT %d + OFFSET %d + ]]):format(buildReportAccessClause(), filterClause, limit, offset) + local params = { jobType, jobType, jobType, identifier, job, jobType } + for _, value in ipairs(filterValues or {}) do + params[#params + 1] = value + end + local reports = MySQL.query.await(reportsQuery, params) + return reports end) ps.registerCallback(resourceName..':server:getReport', function(source, reportid) @@ -318,16 +290,10 @@ ps.registerCallback(resourceName..':server:getReport', function(source, reportid ) FROM mdt_reports_charges mrc WHERE mrc.reportid = mr.id) as charges, (SELECT JSON_ARRAYAGG( JSON_OBJECT( - 'title', mre.title, 'type', mre.type, 'content', mre.content, 'note', mre.note, - 'stored', mre.stored, - 'images', CASE - WHEN mre.images IS NOT NULL AND mre.images != '' - THEN JSON_EXTRACT(mre.images, '$') - ELSE JSON_ARRAY() - END + 'stored', mre.stored ) ) FROM mdt_reports_evidence mre WHERE mre.reportid = mr.id) as evidence, (SELECT JSON_ARRAYAGG( @@ -389,8 +355,7 @@ ps.registerCallback(resourceName..':server:getReport', function(source, reportid ' ', JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.lastname')) ) as fullname, - mp.profilepicture as image, - JSON_UNQUOTE(JSON_EXTRACT(p.metadata, '$.fingerprint')) as fingerprint + mp.profilepicture as image FROM players p LEFT JOIN mdt_profiles mp ON mp.citizenid COLLATE utf8mb4_general_ci = p.citizenid COLLATE utf8mb4_general_ci WHERE p.citizenid COLLATE utf8mb4_general_ci IN (%s) @@ -398,12 +363,10 @@ ps.registerCallback(resourceName..':server:getReport', function(source, reportid local lookupRows = MySQL.query.await(lookupQuery, cidList) local cidInfo = {} - for _, row in ipairs(lookupRows) do - cidInfo[row.citizenid] = { - name = row.fullname, - image = row.image, - fingerprint = (row.fingerprint and row.fingerprint ~= 'null') and row.fingerprint or nil - } + if lookupRows then + for _, row in ipairs(lookupRows) do + cidInfo[row.citizenid] = { name = row.fullname, image = row.image } + end end for _, entry in ipairs(involved) do @@ -411,7 +374,6 @@ ps.registerCallback(resourceName..':server:getReport', function(source, reportid local info = cidInfo[entry.citizenid] entry.name = info.name or entry.name entry.image = info.image - entry.fingerprint = info.fingerprint end end end @@ -551,52 +513,43 @@ ps.registerCallback(resourceName .. ':server:searchVehiclesForReport', function( return {} end - local vehicleShared = nil - local ok, core = pcall(function() return exports['qb-core']:GetCoreObject() end) - if ok and core and core.Shared and core.Shared.Vehicles then - vehicleShared = core.Shared.Vehicles - end - - local likeQuery = '%' .. query:upper() .. '%' - local likeQueryRaw = '%' .. query .. '%' + local likeQuery = '%' .. query .. '%' local rows = MySQL.query.await([[ SELECT pv.plate, pv.vehicle, pv.citizenid, - COALESCE( - NULLIF( - CONCAT( - COALESCE(JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.firstname')), ''), - ' ', - COALESCE(JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.lastname')), '') - ), - ' ' - ), - 'Unknown' + CONCAT( + JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.firstname')), + ' ', + JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.lastname')) ) as owner_name FROM player_vehicles pv LEFT JOIN players p ON p.citizenid COLLATE utf8mb4_general_ci = pv.citizenid COLLATE utf8mb4_general_ci WHERE ( - UPPER(REPLACE(pv.plate, ' ', '')) LIKE REPLACE(?, ' ', '') - OR UPPER(pv.plate) LIKE ? + pv.plate LIKE ? OR CONCAT( - COALESCE(JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.firstname')), ''), + JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.firstname')), ' ', - COALESCE(JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.lastname')), '') + JSON_UNQUOTE(JSON_EXTRACT(p.charinfo, '$.lastname')) ) LIKE ? - OR p.citizenid LIKE ? ) - ORDER BY pv.plate ASC LIMIT 25 - ]], { likeQuery, likeQuery, likeQueryRaw, likeQueryRaw }) + ]], { likeQuery, likeQuery }) local results = {} for _, row in ipairs(rows or {}) do - local vehicleData = vehicleShared and vehicleShared[row.vehicle] or nil + local vehicleData = nil + local ok, core = pcall(function() + return exports['qb-core']:GetCoreObject() + end) + if ok and core and core.Shared and core.Shared.Vehicles then + vehicleData = core.Shared.Vehicles[row.vehicle] + end + table.insert(results, { - plate = row.plate and string.upper(row.plate):gsub('%s+', '') or 'UNKNOWN', + plate = row.plate and string.upper(row.plate) or 'UNKNOWN', vehicle_label = vehicleData and vehicleData.name or row.vehicle or 'Unknown', owner_name = row.owner_name or 'Unknown', owner_citizenid = row.citizenid or nil, @@ -747,21 +700,10 @@ ps.registerCallback(resourceName..':server:saveReport', function(source, reportD if reportData.evidence and #reportData.evidence > 0 then for _, evidence in ipairs(reportData.evidence) do - local imagesJson = nil - if evidence.images and type(evidence.images) == 'table' and #evidence.images > 0 then - imagesJson = json.encode(evidence.images) - end table.insert(attachmentQueries, { - query = "INSERT INTO mdt_reports_evidence (reportid, title, type, content, note, stored, images) VALUES (?, ?, ?, ?, ?, ?, ?)", - values = { - reportId, - evidence.title or '', - evidence.type or 'Physical', - evidence.content or '', - evidence.note or '', - evidence.stored or 0, - imagesJson - } + query = + "INSERT INTO mdt_reports_evidence (reportid, type, content, note, stored) VALUES (?, ?, ?, ?, ?)", + values = { reportId, evidence.type, evidence.content, evidence.note, evidence.stored or 0 } }) end end @@ -1135,4 +1077,4 @@ ps.registerCallback(resourceName .. ':server:getReportsByPlate', function(source ]], { plate }) return rows or {} -end) \ No newline at end of file +end) diff --git a/server/backend/settings.lua b/server/backend/settings.lua index 7378e074..d8b56a40 100644 --- a/server/backend/settings.lua +++ b/server/backend/settings.lua @@ -44,16 +44,6 @@ local ACTION_CATEGORIES = { icu_deleted = 'icu', camera_viewed = 'cameras', bodycam_viewed = 'bodycams', - -- ── Patrol management ──────────────────────────────────────────────────── - patrol_created = 'patrols', - patrol_deleted = 'patrols', - patrol_renamed = 'patrols', - patrol_zone_created = 'patrols', - patrol_zone_updated = 'patrols', - patrol_zone_cleared = 'patrols', - patrol_officer_assigned = 'patrols', - patrol_officer_removed = 'patrols', - patrols_reordered = 'patrols', } -- Cache for tracking config (loaded once, updated on save) @@ -77,7 +67,6 @@ local function getDefaultTracking() icu = true, cameras = true, bodycams = true, - patrols = true, -- ← new } end @@ -421,4 +410,4 @@ ps.registerCallback(resourceName .. ':server:saveColorConfig', function(source, end return { success = true } -end) \ No newline at end of file +end) diff --git a/server/backend/tracking.lua b/server/backend/tracking.lua index 2ced8155..a70a7c4b 100644 --- a/server/backend/tracking.lua +++ b/server/backend/tracking.lua @@ -1,729 +1,162 @@ --- ============================================================================ --- server_patrols.lua — MDT patrol & live tracking (server side) --- ---------------------------------------------------------------------------- --- Responsibilities: --- * Live officer/vehicle tracking served to the map NUI (getTracking). --- * Patrol CRUD + ordering + zone storage, persisted in `mdt_patrols`. --- * Audit logging of every patrol mutation via ps.auditLog. --- --- Performance notes for future devs: --- * getTracking is polled by EVERY open MDT (~every 4.5s in the NUI). The --- result is cached for TRACKING_CACHE_TTL ms so the heavy player/entity --- scan runs at most once per TTL no matter how many MDTs are open. Never --- call getAllTrackers() directly from the callback — use getTrackingSnapshot(). --- * vehicleCache holds recently-parked police vehicles. Entries self-expire --- after VEHICLE_CACHE_TTL so ghost markers disappear even when the --- 'entityRemoved' event never fires (common when entities get culled). --- Cache-served vehicles carry `cached = true` so the NUI can dim them. --- * DB writes for frequent mutations (assign/remove) are debounced --- (SAVE_DEBOUNCE_MS) and coalesced per patrol. Structural changes --- (create/rename/zone/delete) are written immediately so a crash can't --- lose them. --- * broadcastPatrols coalesces plain broadcasts to one per frame. Action --- broadcasts (assigned/removed) bypass coalescing because the NUI needs --- the flash hint immediately. --- --- Set MDT_DEBUG = true for verbose console logging while developing. --- ============================================================================ - local resourceName = tostring(GetCurrentResourceName()) --- ─── Tunables ─────────────────────────────────────────────────────────────── -local MDT_DEBUG = false -- verbose dev logging; KEEP FALSE on production (log spam) -local TRACKING_CACHE_TTL = 2000 -- ms — shared tracking snapshot lifetime -local VEHICLE_CACHE_TTL = 600000 -- ms — parked-vehicle cache entry lifetime (10 min) -local SAVE_DEBOUNCE_MS = 1000 -- ms — coalesce window for debounced patrol saves - --- ─── State ────────────────────────────────────────────────────────────────── -local vehicleCache = {} -- [plate] = { plate, coords, heading, _ts } -local cacheVehicleCooldowns = {} -- [src] = os.time() of last cacheVehicle event -local patrols = {} -- [id] = patrol -local patrolOrder = {} -- ordered list of patrol ids -local trackingCache = { vehicles = {}, bodycams = {}, ts = 0 } - --- ─── Tiny helpers ───────────────────────────────────────────────────────── +local function getOfficerTrackers() + local officers = {} --- Gated dev logger. No-op unless MDT_DEBUG is on, so it's free in production. -local function dbg(...) - if MDT_DEBUG then print('[MDT]', ...) end -end - --- Cache the QBCore object once instead of crossing the export boundary on --- every call. Returns nil on non-QB frameworks (then the `ps`/ESX path is used). -local _qbCore -local function getQBCore() - if _qbCore then return _qbCore end if exports['qb-core'] then - _qbCore = exports['qb-core']:GetCoreObject() - end - return _qbCore -end - --- ─── Validation ───────────────────────────────────────────────────────────── - -local function isValidPatrolId(id) - return type(id) == "string" and #id > 0 and #id <= 64 -end -local function isValidColor(color) - return type(color) == "string" and color:match("^#%x%x%x%x%x%x$") -end -local function isValidName(name) - return type(name) == "string" and #name > 0 and #name <= 64 -end -local function isValidCitizenId(cid) - return type(cid) == "string" and #cid > 0 and #cid <= 64 -end - --- Validate zone_points: array of {x, y} pairs, max 64 points -local function isValidZonePoints(points) - if points == nil then return true end -- nil = no zone, valid - if type(points) ~= "table" then return false end - if #points > 64 then return false end - for _, pt in ipairs(points) do - if type(pt) ~= "table" then return false end - if type(pt.x) ~= "number" or type(pt.y) ~= "number" then return false end - if pt.x < -10000 or pt.x > 10000 or pt.y < -10000 or pt.y > 10000 then return false end - end - return true -end - --- ─── Audit helpers ──────────────────────────────────────────────────────── - -local function getOfficerInfo(src) - local QBCore = getQBCore() - if QBCore then - local player = QBCore.Functions.GetPlayer(src) - if player then - local d = player.PlayerData - return { - citizenid = d.citizenid, - name = d.charinfo.firstname .. ' ' .. d.charinfo.lastname, - callsign = d.metadata and d.metadata.callsign or nil, - rank = d.job and d.job.grade and d.job.grade.name or nil, - job = d.job and d.job.name or nil, - } + local QBCore = exports['qb-core']:GetCoreObject() + local players = QBCore.Functions.GetQBPlayers() or {} + for _, player in pairs(players) do + local data = player.PlayerData + if data and data.job and data.job.onduty then + if IsPoliceJob(data.job.name, data.job.type) then + local src = data.source + local ped = GetPlayerPed(src) + if ped and ped ~= 0 then + local coords = GetEntityCoords(ped) + local coordsTable = { x = coords.x, y = coords.y, z = coords.z } + local heading = GetEntityHeading(ped) + officers[#officers + 1] = { + citizenid = data.citizenid, + name = (data.charinfo.firstname .. ' ' .. data.charinfo.lastname), + callsign = data.metadata and data.metadata.callsign or nil, + rank = data.job.grade and data.job.grade.name or 'Officer', + coords = coordsTable, + heading = heading, + } + end + end + end end - elseif ps and ps.getIdentifier then - local name = (ps.getPlayerName and ps.getPlayerName(src)) or GetPlayerName(src) or 'Unknown' - return { - citizenid = ps.getIdentifier(src), - name = name, - callsign = ps.getMetadata and ps.getMetadata(src, 'callsign') or nil, - rank = ps.getJobGradeName and ps.getJobGradeName(src) or nil, - job = ps.getJobName and ps.getJobName(src) or nil, - } - end - return { name = GetPlayerName(src) or ('Player #' .. src) } -end - --- Resolve a citizenid -> "Firstname Lastname" without scanning every online --- player. GetPlayerByCitizenId is a direct lookup on QBCore. -local function getNameByCitizenId(citizenId) - local QBCore = getQBCore() - if QBCore and QBCore.Functions.GetPlayerByCitizenId then - local p = QBCore.Functions.GetPlayerByCitizenId(citizenId) - if p and p.PlayerData and p.PlayerData.charinfo then - return p.PlayerData.charinfo.firstname .. ' ' .. p.PlayerData.charinfo.lastname + return officers + end + + if ps and ps.getAllPlayers then + local players = ps.getAllPlayers() or {} + for _, playerId in pairs(players) do + if ps.getJobDuty and ps.getJobDuty(playerId) then + local jobName = ps.getJobName and ps.getJobName(playerId) or nil + local jobType = ps.getJobType and ps.getJobType(playerId) or nil + if IsPoliceJob(jobName, jobType) then + local ped = GetPlayerPed(playerId) + if ped and ped ~= 0 then + local coords = GetEntityCoords(ped) + local coordsTable = { x = coords.x, y = coords.y, z = coords.z } + local heading = GetEntityHeading(ped) + officers[#officers + 1] = { + citizenid = ps.getIdentifier and ps.getIdentifier(playerId) or nil, + name = ps.getPlayerName and ps.getPlayerName(playerId) or GetPlayerName(playerId) or 'Unknown', + callsign = ps.getMetadata and ps.getMetadata(playerId, 'callsign') or nil, + rank = ps.getJobGradeName and ps.getJobGradeName(playerId) or 'Officer', + coords = coordsTable, + heading = heading, + } + end + end + end end end - return citizenId -end - -local function auditPatrol(src, action, patrolId, extra) - if not ps.auditLog then return end - local officer = getOfficerInfo(src) - local data = { - officer_name = officer.name, - officer_callsign = officer.callsign, - officer_rank = officer.rank, - officer_id = officer.citizenid, - } - if extra then - for k, v in pairs(extra) do data[k] = v end - end - ps.auditLog(src, action, 'mdt_patrol', patrolId or 'none', data) -end - --- ─── DB ───────────────────────────────────────────────────────────────────── --- Immediate write. Used directly for structural changes; the debounced --- savePatrol() below funnels frequent membership writes through this. -local function savePatrolNow(patrol) - if not patrol then return end - MySQL.insert( - "INSERT INTO mdt_patrols (id, name, color, member_ids, sort_order, zone_points) VALUES (?, ?, ?, ?, ?, ?) " .. - "ON DUPLICATE KEY UPDATE name = VALUES(name), color = VALUES(color), " .. - "member_ids = VALUES(member_ids), sort_order = VALUES(sort_order), zone_points = VALUES(zone_points)", - { - patrol.id, - patrol.name, - patrol.color, - json.encode(patrol.memberIds), - patrol.sortOrder or 0, - patrol.zonePoints and json.encode(patrol.zonePoints) or nil, - } - ) + return officers end --- Debounced save: coalesces rapid writes for the same patrol into a single DB --- write after SAVE_DEBOUNCE_MS. Heavy assign/remove churn no longer hits the DB --- on every click. Always references the live patrol table, so the flush writes --- the latest state. delete cancels any pending save (see deletePatrol). -local pendingSaves = {} -- [id] = patrol -local saveTimerArmed = false - -local function flushSaves() - saveTimerArmed = false - for id, patrol in pairs(pendingSaves) do - pendingSaves[id] = nil - savePatrolNow(patrol) - end -end - -local function savePatrol(patrol) - if not patrol then return end - pendingSaves[patrol.id] = patrol - if not saveTimerArmed then - saveTimerArmed = true - SetTimeout(SAVE_DEBOUNCE_MS, flushSaves) - end -end - -local function deletePatrolFromDB(id) - MySQL.execute("DELETE FROM mdt_patrols WHERE id = ?", { id }) -end - -local function saveOrder() - for i, id in ipairs(patrolOrder) do - if patrols[id] then - patrols[id].sortOrder = i - MySQL.execute("UPDATE mdt_patrols SET sort_order = ? WHERE id = ?", { i, id }) - end - end -end - --- ─── Broadcast ────────────────────────────────────────────────────────────── +local function getVehicleTrackers() + local vehicles = {} + local seen = {} -local function doBroadcast(action, citizenid) - local ordered = {} - for _, id in ipairs(patrolOrder) do - if patrols[id] then - ordered[#ordered + 1] = patrols[id] + if exports['qb-core'] then + local QBCore = exports['qb-core']:GetCoreObject() + local players = QBCore.Functions.GetQBPlayers() or {} + for _, player in pairs(players) do + local data = player.PlayerData + if data and data.job and data.job.onduty and IsPoliceJob(data.job.name, data.job.type) then + local ped = GetPlayerPed(data.source) + if ped and ped ~= 0 then + local veh = GetVehiclePedIsIn(ped, false) + if veh and veh ~= 0 and not seen[veh] then + seen[veh] = true + local coords = GetEntityCoords(veh) + local coordsTable = { x = coords.x, y = coords.y, z = coords.z } + local heading = GetEntityHeading(veh) + local plate = GetVehicleNumberPlateText(veh) + vehicles[#vehicles + 1] = { + plate = plate, + coords = coordsTable, + heading = heading, + } + end + end + end end end - TriggerClientEvent(resourceName .. ":client:syncPatrols", -1, ordered, action, citizenid) -end --- Plain broadcasts (create/delete/rename/reorder/zone/disconnect) are coalesced --- to one per frame so a burst of mutations doesn't fan the full list out to --- every client multiple times. Action broadcasts (assigned/removed) carry a --- flash hint the NUI needs, so they go out immediately. -local broadcastScheduled = false -local function broadcastPatrols(action, citizenid) - if action then - doBroadcast(action, citizenid) - return - end - if broadcastScheduled then return end - broadcastScheduled = true - SetTimeout(0, function() - broadcastScheduled = false - doBroadcast() - end) + return vehicles end --- ─── Tracking ───────────────────────────────────────────────────────────── - --- HEAVY: scans all on-duty police players + their vehicles. Do not call this --- per client request — it's wrapped by getTrackingSnapshot() which caches it. -local function getAllTrackers() - local vehicles = {} +local function getBodycamTrackers() local bodycams = {} - local seenVehicles = {} - local now = GetGameTimer() - local QBCore = getQBCore() - if QBCore then + if exports['qb-core'] then + local QBCore = exports['qb-core']:GetCoreObject() local players = QBCore.Functions.GetQBPlayers() or {} - for _, player in pairs(players) do local data = player.PlayerData - if not data or not data.job or not data.job.onduty then goto continue end - if not IsPoliceJob(data.job.name, data.job.type) then goto continue end - - local src = data.source - local ped = GetPlayerPed(src) - if not ped or ped == 0 then goto continue end - - local coords = GetEntityCoords(ped) - local veh = GetVehiclePedIsIn(ped, false) - bodycams[#bodycams + 1] = { - citizenid = data.citizenid, - name = data.charinfo.firstname .. ' ' .. data.charinfo.lastname, - callsign = data.metadata and data.metadata.callsign or nil, - rank = data.job.grade and data.job.grade.name or 'Officer', - coords = { x = coords.x, y = coords.y, z = coords.z }, - heading = GetEntityHeading(ped), - inVehicle = veh and veh ~= 0, - } - - if veh and veh ~= 0 and not seenVehicles[veh] then - seenVehicles[veh] = true - local vCoords = GetEntityCoords(veh) - local vHeading = GetEntityHeading(veh) - local plate = GetVehicleNumberPlateText(veh):gsub('%s+', '') - local coordsTbl = { x = vCoords.x, y = vCoords.y, z = vCoords.z } - vehicles[#vehicles + 1] = { plate = plate, coords = coordsTbl, heading = vHeading } - vehicleCache[plate] = { plate = plate, coords = coordsTbl, heading = vHeading, _ts = now } + if data and data.job and data.job.onduty then + if IsPoliceJob(data.job.name, data.job.type) then + local ped = GetPlayerPed(data.source) + if ped and ped ~= 0 then + local coords = GetEntityCoords(ped) + local coordsTable = { x = coords.x, y = coords.y, z = coords.z } + local heading = GetEntityHeading(ped) + bodycams[#bodycams + 1] = { + citizenid = data.citizenid, + name = (data.charinfo.firstname .. ' ' .. data.charinfo.lastname), + callsign = data.metadata and data.metadata.callsign or nil, + coords = coordsTable, + heading = heading, + } + end + end end - ::continue:: end - - elseif ps and ps.getAllPlayers then - local playerList = ps.getAllPlayers() or {} - for _, playerId in pairs(playerList) do - if not (ps.getJobDuty and ps.getJobDuty(playerId)) then goto continue end - local jobName = ps.getJobName and ps.getJobName(playerId) or nil - local jobType = ps.getJobType and ps.getJobType(playerId) or nil - if not IsPoliceJob(jobName, jobType) then goto continue end - - local ped = GetPlayerPed(playerId) - if not ped or ped == 0 then goto continue end - - local coords = GetEntityCoords(ped) - local veh = GetVehiclePedIsIn(ped, false) - bodycams[#bodycams + 1] = { - citizenid = ps.getIdentifier and ps.getIdentifier(playerId) or nil, - name = (ps.getPlayerName and ps.getPlayerName(playerId)) or GetPlayerName(playerId) or 'Unknown', - callsign = ps.getMetadata and ps.getMetadata(playerId, 'callsign') or nil, - rank = ps.getJobGradeName and ps.getJobGradeName(playerId) or 'Officer', - coords = { x = coords.x, y = coords.y, z = coords.z }, - heading = GetEntityHeading(ped), - inVehicle = veh and veh ~= 0, - } - - if veh and veh ~= 0 and not seenVehicles[veh] then - seenVehicles[veh] = true - local vCoords = GetEntityCoords(veh) - local vHeading = GetEntityHeading(veh) - local plate = GetVehicleNumberPlateText(veh):gsub('%s+', '') - local coordsTbl = { x = vCoords.x, y = vCoords.y, z = vCoords.z } - vehicles[#vehicles + 1] = { plate = plate, coords = coordsTbl, heading = vHeading } - vehicleCache[plate] = { plate = plate, coords = coordsTbl, heading = vHeading, _ts = now } + return bodycams + end + + if ps and ps.getAllPlayers then + local players = ps.getAllPlayers() or {} + for _, playerId in pairs(players) do + if ps.getJobDuty and ps.getJobDuty(playerId) then + local jobName = ps.getJobName and ps.getJobName(playerId) or nil + local jobType = ps.getJobType and ps.getJobType(playerId) or nil + if IsPoliceJob(jobName, jobType) then + local ped = GetPlayerPed(playerId) + if ped and ped ~= 0 then + local coords = GetEntityCoords(ped) + local coordsTable = { x = coords.x, y = coords.y, z = coords.z } + local heading = GetEntityHeading(ped) + bodycams[#bodycams + 1] = { + citizenid = ps.getIdentifier and ps.getIdentifier(playerId) or nil, + name = ps.getPlayerName and ps.getPlayerName(playerId) or GetPlayerName(playerId) or 'Unknown', + callsign = ps.getMetadata and ps.getMetadata(playerId, 'callsign') or nil, + coords = coordsTable, + heading = heading, + } + end + end end - ::continue:: - end - end - - -- Merge in cached (parked / recently-left) police vehicles that weren't - -- seen live this pass. Stale entries are pruned here so ghost markers vanish - -- after VEHICLE_CACHE_TTL even if 'entityRemoved' never fires. These carry - -- `cached = true` so the NUI can render them dimmed ("last known position"). - local seenPlates = {} - for _, v in ipairs(vehicles) do seenPlates[v.plate] = true end - for plate, cacheData in pairs(vehicleCache) do - if (now - (cacheData._ts or 0)) > VEHICLE_CACHE_TTL then - vehicleCache[plate] = nil - elseif not seenPlates[plate] then - vehicles[#vehicles + 1] = { - plate = cacheData.plate, - coords = cacheData.coords, - heading = cacheData.heading, - cached = true, - } - seenPlates[plate] = true end end - return vehicles, bodycams -end - --- Returns a shared, throttled snapshot. With N MDTs open the expensive scan --- runs at most once per TRACKING_CACHE_TTL instead of N times per poll cycle. -local function getTrackingSnapshot() - local now = GetGameTimer() - if trackingCache.ts ~= 0 and (now - trackingCache.ts) < TRACKING_CACHE_TTL then - return trackingCache - end - local vehicles, bodycams = getAllTrackers() - trackingCache.vehicles = vehicles - trackingCache.bodycams = bodycams - trackingCache.ts = now - return trackingCache + return bodycams end ps.registerCallback(resourceName .. ':server:getTracking', function(source) - if not CheckAuth(source) then - return { vehicles = {}, bodycams = {} } - end - local snap = getTrackingSnapshot() - return { vehicles = snap.vehicles, bodycams = snap.bodycams } -end) - -RegisterNetEvent(resourceName .. ':server:cacheVehicle', function(plate, coords, heading) local src = source - -- Defense in depth: only authorised, on-duty police may inject cache markers. - if not CheckAuth(src) then return end - if type(plate) ~= 'string' or #plate == 0 or #plate > 8 then return end - if type(coords) ~= 'table' or type(coords.x) ~= 'number' or type(coords.y) ~= 'number' or type(coords.z) ~= 'number' then return end - if type(heading) ~= 'number' or heading < 0 or heading > 360 then return end - if coords.x < -4000 or coords.x > 4000 or coords.y < -4000 or coords.y > 8000 then return end - - local nowSec = os.time() - if cacheVehicleCooldowns[src] and nowSec - cacheVehicleCooldowns[src] < 5 then return end - cacheVehicleCooldowns[src] = nowSec + if not CheckAuth(src) then return { officers = {}, vehicles = {}, bodycams = {} } end - -- Per-framework duty/police check (QB path and ps/ESX path). - local QBCore = getQBCore() - if QBCore then - local player = QBCore.Functions.GetPlayer(src) - if not player then return end - local job = player.PlayerData.job - if not job or not job.onduty or not IsPoliceJob(job.name, job.type) then return end - elseif ps and ps.getJobName then - if not (ps.getJobDuty and ps.getJobDuty(src)) then return end - local jobName = ps.getJobName(src) - local jobType = ps.getJobType and ps.getJobType(src) or nil - if not IsPoliceJob(jobName, jobType) then return end - end - - vehicleCache[plate] = { - plate = plate, - coords = { x = coords.x, y = coords.y, z = coords.z }, - heading = heading, - _ts = GetGameTimer(), + return { + officers = getOfficerTrackers(), + vehicles = getVehicleTrackers(), + bodycams = getBodycamTrackers(), } end) - -RegisterNetEvent('baseevents:leftVehicle', function(vehicle, seat, model, netId) - local src = source - if not CheckAuth(src) then return end - local veh = NetworkGetEntityFromNetworkId(netId) - if not veh or veh == 0 then return end - local coords = GetEntityCoords(veh) - local heading = GetEntityHeading(veh) - local plate = GetVehicleNumberPlateText(veh):gsub('%s+', '') - if not plate or #plate == 0 then return end - TriggerClientEvent(resourceName .. ':client:checkVehicleClass', src, netId, plate, - { x = coords.x, y = coords.y, z = coords.z }, heading) -end) - -AddEventHandler('entityRemoved', function(entity) - if GetEntityType(entity) ~= 2 then return end - local plate = GetVehicleNumberPlateText(entity) - if not plate or plate == '' then return end - plate = plate:gsub('%s+', '') - vehicleCache[plate] = nil -end) - --- ─── Patrols ────────────────────────────────────────────────────────────── - -ps.registerCallback(resourceName .. ":server:getPatrols", function(source) - local src = source - if not CheckAuth(src) then return {} end - local ordered = {} - for _, id in ipairs(patrolOrder) do - if patrols[id] then - ordered[#ordered + 1] = patrols[id] - end - end - return ordered -end) - -RegisterNetEvent(resourceName .. ":server:createPatrol", function(id, name, color) - local src = source - if not CheckAuth(src) then return end - if not isValidPatrolId(id) or not isValidName(name) or not isValidColor(color) then return end - if patrols[id] then return end - - local sortOrder = #patrolOrder + 1 - patrols[id] = { id = id, name = name, color = color, memberIds = {}, zonePoints = nil, sortOrder = sortOrder } - patrolOrder[#patrolOrder + 1] = id - broadcastPatrols() - savePatrolNow(patrols[id]) -- structural change → write immediately - dbg(('patrol created: "%s" (%s)'):format(name, id)) - auditPatrol(src, 'patrol_created', id, { - patrol_name = name, - patrol_color = color, - action_label = ('Created patrol "%s"'):format(name), - }) -end) - -RegisterNetEvent(resourceName .. ":server:deletePatrol", function(id) - local src = source - if not CheckAuth(src) then return end - if not isValidPatrolId(id) then return end - if not patrols[id] then return end - - -- Capture the name BEFORE removing the entry (previous code read it after - -- nil-ing patrols[id], so the audit log always showed the raw id). - local deletedName = patrols[id].name or id - - pendingSaves[id] = nil -- cancel any debounced save so it can't re-insert the row - patrols[id] = nil - for i = #patrolOrder, 1, -1 do - if patrolOrder[i] == id then - table.remove(patrolOrder, i) - break - end - end - deletePatrolFromDB(id) - saveOrder() - broadcastPatrols() - dbg(('patrol deleted: "%s" (%s)'):format(deletedName, id)) - auditPatrol(src, 'patrol_deleted', id, { - patrol_name = deletedName, - action_label = ('Deleted patrol "%s"'):format(deletedName), - }) -end) - -RegisterNetEvent(resourceName .. ":server:renamePatrol", function(id, newName) - local src = source - if not CheckAuth(src) then return end - if not isValidPatrolId(id) or not isValidName(newName) then return end - if not patrols[id] then return end - - local oldName = patrols[id].name - patrols[id].name = newName - broadcastPatrols() - savePatrolNow(patrols[id]) -- structural change → write immediately - dbg(('patrol renamed: "%s" -> "%s"'):format(oldName, newName)) - auditPatrol(src, 'patrol_renamed', id, { - patrol_old_name = oldName, - patrol_new_name = newName, - action_label = ('Renamed patrol "%s" → "%s"'):format(oldName, newName), - }) -end) - --- ─── Zone Points ──────────────────────────────────────────────────────────── --- Client sends updated zone_points for a patrol after drawing on the map. --- points = array of { x, y } in GTA world coordinates, or nil to clear zone. -RegisterNetEvent(resourceName .. ":server:setPatrolZone", function(id, points) - local src = source - if not CheckAuth(src) then return end - if not isValidPatrolId(id) then return end - if not patrols[id] then return end - if not isValidZonePoints(points) then return end - - -- nil clears the zone; fewer than 3 points also clears it - local hadZone = patrols[id].zonePoints ~= nil - patrols[id].zonePoints = (points and #points >= 3) and points or nil - broadcastPatrols() - savePatrolNow(patrols[id]) -- zone data is precious → write immediately - local zoneAction = patrols[id].zonePoints - and (hadZone and 'patrol_zone_updated' or 'patrol_zone_created') - or 'patrol_zone_cleared' - local pts = patrols[id].zonePoints and #patrols[id].zonePoints or 0 - local label = patrols[id].zonePoints - and ('Drew zone for patrol "%s" (%d points)'):format(patrols[id].name, pts) - or ('Cleared zone for patrol "%s"'):format(patrols[id].name) - dbg(label) - auditPatrol(src, zoneAction, id, { - patrol_name = patrols[id].name, - point_count = pts, - action_label = label, - }) -end) - --- ─── Order / Assign ─────────────────────────────────────────────────────── - -RegisterNetEvent(resourceName .. ":server:reorderPatrols", function(ids) - local src = source - if not CheckAuth(src) then return end - if type(ids) ~= "table" then return end - - local seen = {} - local newOrder = {} - for _, id in ipairs(ids) do - if isValidPatrolId(id) and patrols[id] and not seen[id] then - seen[id] = true - newOrder[#newOrder + 1] = id - end - end - for _, id in ipairs(patrolOrder) do - if not seen[id] then - newOrder[#newOrder + 1] = id - end - end - - patrolOrder = newOrder - saveOrder() - broadcastPatrols() - local nameOrder = {} - for _, pid in ipairs(newOrder) do - nameOrder[#nameOrder + 1] = patrols[pid] and patrols[pid].name or pid - end - dbg('patrols reordered: ' .. table.concat(nameOrder, ' -> ')) - -- NOTE: previous code passed `extra` as a 5th arg (after a nil), so it was - -- silently dropped. auditPatrol is (src, action, patrolId, extra). - auditPatrol(src, 'patrols_reordered', 'order', { - new_order = table.concat(nameOrder, ' → '), - action_label = 'Reordered patrols: ' .. table.concat(nameOrder, ' → '), - }) -end) - -RegisterNetEvent(resourceName .. ":server:assignOfficer", function(patrolId, citizenId) - local src = source - if not CheckAuth(src) then return end - if not isValidPatrolId(patrolId) or not isValidCitizenId(citizenId) then return end - if not patrols[patrolId] then return end - - for _, patrol in pairs(patrols) do - for i = #patrol.memberIds, 1, -1 do - if patrol.memberIds[i] == citizenId then - table.remove(patrol.memberIds, i) - end - end - end - table.insert(patrols[patrolId].memberIds, citizenId) - broadcastPatrols("assigned", citizenId) - savePatrol(patrols[patrolId]) -- frequent mutation → debounced - - local assignedName = getNameByCitizenId(citizenId) - dbg(('assigned %s to "%s"'):format(assignedName, patrols[patrolId].name)) - auditPatrol(src, 'patrol_officer_assigned', patrolId, { - patrol_name = patrols[patrolId].name, - assigned_name = assignedName, - assigned_id = citizenId, - action_label = ('Assigned %s to patrol "%s"'):format(assignedName, patrols[patrolId].name), - }) -end) - -RegisterNetEvent(resourceName .. ":server:removeFromPatrol", function(citizenId) - local src = source - if not CheckAuth(src) then return end - if not isValidCitizenId(citizenId) then return end - - -- Find which patrol the officer belongs to BEFORE removal (for the audit log) - local removedFromPatrol = 'unknown' - for _, patrol in pairs(patrols) do - for _, mid in ipairs(patrol.memberIds) do - if mid == citizenId then removedFromPatrol = patrol.name; break end - end - end - for _, patrol in pairs(patrols) do - for i = #patrol.memberIds, 1, -1 do - if patrol.memberIds[i] == citizenId then - table.remove(patrol.memberIds, i) - savePatrol(patrol) -- frequent mutation → debounced - end - end - end - broadcastPatrols("removed", citizenId) - -- Only audit if the officer was actually found in a patrol - if removedFromPatrol ~= 'unknown' then - dbg(('removed %s from "%s"'):format(citizenId, removedFromPatrol)) - auditPatrol(src, 'patrol_officer_removed', citizenId, { - removed_id = citizenId, - removed_from = removedFromPatrol, - action_label = ('Removed officer from patrol "%s"'):format(removedFromPatrol), - }) - end -end) - -AddEventHandler("playerDropped", function() - local src = source - cacheVehicleCooldowns[src] = nil - - local citizenId = nil - local officerName = GetPlayerName(src) or ('Player #' .. src) - - local QBCore = getQBCore() - if QBCore then - local player = QBCore.Functions.GetPlayer(src) - if player then - citizenId = player.PlayerData.citizenid - officerName = player.PlayerData.charinfo.firstname .. ' ' .. player.PlayerData.charinfo.lastname - end - elseif ps and ps.getIdentifier then - citizenId = ps.getIdentifier(src) - officerName = (ps.getPlayerName and ps.getPlayerName(src)) or officerName - end - - if not citizenId then return end - - -- Find patrol membership BEFORE removal so we can log it - local removedFromPatrol = nil - local removedFromId = nil - for pid, patrol in pairs(patrols) do - for _, mid in ipairs(patrol.memberIds) do - if mid == citizenId then - removedFromPatrol = patrol.name - removedFromId = pid - break - end - end - if removedFromPatrol then break end - end - - -- Remove from patrol - for _, patrol in pairs(patrols) do - for i = #patrol.memberIds, 1, -1 do - if patrol.memberIds[i] == citizenId then - table.remove(patrol.memberIds, i) - savePatrol(patrol) -- frequent mutation → debounced - end - end - end - - broadcastPatrols() - - -- Audit log only if they were actually in a patrol - if removedFromPatrol and ps.auditLog then - dbg(('%s disconnected from patrol "%s"'):format(officerName, removedFromPatrol)) - ps.auditLog(src, 'patrol_officer_removed', removedFromId or citizenId, { - officer_name = officerName, - officer_id = citizenId, - removed_from = removedFromPatrol, - action_label = ('%s left patrol "%s" (disconnected)'):format(officerName, removedFromPatrol), - }) - end -end) - -AddEventHandler("onResourceStart", function(res) - if res ~= resourceName then return end - - -- Wrapped in pcall so a missing/broken `mdt_patrols` table produces a clear - -- error for the next dev instead of a hard crash on boot. - local ok, rows = pcall(function() - return MySQL.query.await("SELECT * FROM mdt_patrols ORDER BY sort_order ASC") - end) - if not ok or type(rows) ~= "table" then - print(('^1[MDT]^7 Failed to load patrols. Is the `mdt_patrols` table installed? Error: %s') - :format(tostring(rows))) - return - end - - patrolOrder = {} - for _, row in ipairs(rows) do - local zonePoints = nil - if row.zone_points and row.zone_points ~= "" and row.zone_points ~= "null" then - local okDecode, decoded = pcall(json.decode, row.zone_points) - if okDecode and type(decoded) == "table" then - zonePoints = decoded - else - print(('^3[MDT]^7 Patrol "%s" has unparseable zone_points; ignoring.'):format(row.id)) - end - end - patrols[row.id] = { - id = row.id, - name = row.name, - color = row.color, - memberIds = {}, - zonePoints = zonePoints, - sortOrder = row.sort_order or 0, - } - patrolOrder[#patrolOrder + 1] = row.id - end - -- Members reset on restart (officers need to be reassigned after a restart) - MySQL.execute("UPDATE mdt_patrols SET member_ids = '[]'", {}) - ps.debug(('^2[MDT]^7 Loaded %d patrol(s).') - :format(#rows)) -end) - --- Flush any pending debounced saves before the resource stops so nothing queued --- in the last SAVE_DEBOUNCE_MS window is lost on restart. -AddEventHandler("onResourceStop", function(res) - if res ~= resourceName then return end - flushSaves() -end) \ No newline at end of file diff --git a/server/backend/vehicles.lua b/server/backend/vehicles.lua index 162c8b0d..5e27fd61 100644 --- a/server/backend/vehicles.lua +++ b/server/backend/vehicles.lua @@ -31,16 +31,6 @@ local function formatLabel(value) return formatted end -local function parseStatus(raw) - if not raw or raw == '' then return 'valid', '' end - local ok, decoded = pcall(function() return json.decode(raw) end) - if ok and type(decoded) == 'table' then - return decoded.status or 'valid', decoded.reason or '' - end - -- Fallback für alte plain-text Werte - return raw, '' -end - local function getVehicleShared(model) if not Core or not Core.Shared or not Core.Shared.Vehicles then return nil @@ -128,8 +118,7 @@ ps.registerCallback(resourceName .. ':server:GetVehicles', function(source) local plate = v.plate and string.upper(v.plate) or 'UNKNOWN' local reportCount = countSetItems(reportIdsByPlate[plate]) local hasActiveBolo = activeBoloByPlate[plate] == true or v.boloactive == 1 - local statusName, statusReason = parseStatus(v.status) - local flags = buildVehicleFlags(v.stolen == 1, hasActiveBolo, statusName) + local flags = buildVehicleFlags(v.stolen == 1, hasActiveBolo, v.status) table.insert(vehicles, { id = v.id, @@ -143,8 +132,7 @@ ps.registerCallback(resourceName .. ':server:GetVehicles', function(source) image = (v.image and v.image ~= '' and v.image) or ('https://docs.fivem.net/vehicles/' .. v.vehicle .. '.webp'), seenIn = reportCount, points = tonumber(v.points) or 0, - status = statusName, - reason = statusReason, + status = v.status or 'valid', core_state = tonumber(v.core_state) or 0, }) end @@ -205,21 +193,14 @@ ps.registerCallback(resourceName .. ':server:UpdateVehicle', function(source, pa values[#values + 1] = payload.information end - if payload.image ~= nil then - updates[#updates + 1] = 'mdt_vehicle_image = ?' - values[#values + 1] = payload.image - end - if points ~= nil then updates[#updates + 1] = 'mdt_vehicle_points = ?' values[#values + 1] = points end if status ~= nil then - local reason = payload.reason or '' - local encoded = json.encode({ status = status, reason = reason }) updates[#updates + 1] = 'mdt_vehicle_status = ?' - values[#values + 1] = encoded + values[#values + 1] = status end if #updates == 0 then @@ -297,8 +278,7 @@ ps.registerCallback(resourceName .. ':server:GetVehicle', function(source, plate end local reportCount = countSetItems(reportIdSet) - local statusName, statusReason = parseStatus(row.status) - local flags = buildVehicleFlags(row.stolen == 1, hasActiveBolo or row.boloactive == 1, statusName) + local flags = buildVehicleFlags(row.stolen == 1, hasActiveBolo or row.boloactive == 1, row.status) return { success = true, @@ -314,8 +294,7 @@ ps.registerCallback(resourceName .. ':server:GetVehicle', function(source, plate image = (row.image and row.image ~= '' and row.image) or ('https://docs.fivem.net/vehicles/' .. row.vehicle .. '.webp'), information = row.information or '', points = tonumber(row.points) or 0, - status = statusName, - reason = statusReason, + status = row.status or 'valid', core_state = tonumber(row.core_state) or 0, stolen = row.stolen == 1, boloactive = row.boloactive == 1, diff --git a/server/backend/weapons.lua b/server/backend/weapons.lua index 64d5c76a..aa6d9e79 100644 --- a/server/backend/weapons.lua +++ b/server/backend/weapons.lua @@ -113,36 +113,19 @@ exports('registerWeapon', registerWeapon) ps.registerCallback('ps-mdt:server:getWeapons', function(source) if not CheckAuth(source) then return {} end - local weapons = MySQL.query.await('SELECT * FROM mdt_weapons') or {} + local weapons = MySQL.query.await('SELECT * FROM mdt_weapons') local newData = {} local weaponBolo = {} - - -- Batch-resolve owner names in a single query to avoid an N+1 lookup per weapon - local nameByOwner = {} - do - local owners, seen = {}, {} - for _, v in pairs(weapons) do - if v.owner and v.owner ~= '' and not seen[v.owner] then - seen[v.owner] = true - owners[#owners + 1] = v.owner - end - end - if #owners > 0 then - local placeholders = string.rep('?,', #owners - 1) .. '?' - local profiles = MySQL.query.await('SELECT citizenid, fullname FROM mdt_profiles WHERE citizenid IN (' .. placeholders .. ')', owners) or {} - for _, p in ipairs(profiles) do - if p.fullname and p.fullname ~= '' then - nameByOwner[p.citizenid] = p.fullname - end - end - end - end - for k, v in pairs(weapons) do - -- Resolve owner name: batched mdt_profiles lookup first, then ps_lib fallback + -- Resolve owner name: try mdt_profiles first, then ps_lib lookup local ownerName = 'Unknown' if v.owner and v.owner ~= '' then - ownerName = nameByOwner[v.owner] or ps.getPlayerNameByIdentifier(v.owner) or 'Unknown' + local profile = MySQL.single.await('SELECT fullname FROM mdt_profiles WHERE citizenid = ?', { v.owner }) + if profile and profile.fullname and profile.fullname ~= '' then + ownerName = profile.fullname + else + ownerName = ps.getPlayerNameByIdentifier(v.owner) or 'Unknown' + end end -- Normalize weapon model to lowercase for class table lookup @@ -158,7 +141,6 @@ ps.registerCallback('ps-mdt:server:getWeapons', function(source) name = (QBCore and QBCore.Shared and QBCore.Shared.Weapons and QBCore.Shared.Weapons[GetHashKey(v.weaponModel)] and QBCore.Shared.Weapons[GetHashKey(v.weaponModel)].label) or v.weaponModel, image = 'https://docs.fivem.net/weapons/' .. v.weaponModel:upper() .. '.png', type = class[modelLower] and class[modelLower].type or 'unknown', - flags = v.flags and json.decode(v.flags) or {}, } table.insert(newData, weaponInfo) end @@ -178,56 +160,20 @@ ps.registerCallback('ps-mdt:server:getWeapons', function(source) return { weapons = newData, bolos = weaponBolo } end) -ps.registerCallback(resourceName .. ':server:getWeaponOwnershipHistory', function(source, payload) +ps.registerCallback(resourceName .. ':server:getWeaponOwnershipHistory', function(source, serial) local src = source - if not CheckAuth(src) then return {} end - - local serial = payload - + if not CheckAuth(src) then return end if not serial or serial == '' then return {} end local rows = MySQL.query.await([[ - SELECT h.id, h.serial, h.owner, h.weapon_model, h.weapon_class, h.information, h.changed_by, h.reason, h.created_at, - p.fullname AS owner_name, - cb.fullname AS changed_by_name - FROM mdt_weapon_ownership_history h - LEFT JOIN mdt_profiles p ON p.citizenid = h.owner - LEFT JOIN mdt_profiles cb ON cb.citizenid = h.changed_by - WHERE h.serial = ? - ORDER BY h.created_at DESC + SELECT id, serial, owner, weapon_model, weapon_class, information, changed_by, reason, created_at + FROM mdt_weapon_ownership_history + WHERE serial = ? + ORDER BY created_at DESC ]], { serial }) - return rows or {} end) -ps.registerCallback(resourceName .. ':server:getWeaponConfig', function(source) - return { weapons = Config.Weapons } -end) - -ps.registerCallback(resourceName .. ':server:saveWeaponFlags', function(source, serial, flags) - local src = source - if not CheckAuth(src) then return { success = false } end - if not serial or serial == '' then return { success = false } end - - local decoded - if type(flags) == 'table' then - decoded = flags - elseif type(flags) == 'string' then - decoded = json.decode(flags) or {} - else - decoded = {} - end - - local encoded = json.encode(decoded) - local affected = MySQL.update.await('UPDATE mdt_weapons SET flags = ? WHERE serial = ?', { encoded, serial }) - - if affected and affected > 0 then - return { success = true } - else - return { success = false, message = 'Database error' } - end -end) - -- Save/Edit Weapon Info (from NUI) ps.registerCallback(resourceName .. ':server:saveWeaponInfo', function(source, payload) local src = source @@ -245,11 +191,6 @@ ps.registerCallback(resourceName .. ':server:saveWeaponInfo', function(source, p return { success = false, message = 'Missing serial number' } end - -- Ensure profile exists before insert/update to avoid FK constraint error - -- if owner and owner ~= '' then - -- EnsureProfileExists(owner) - -- end - local existing = MySQL.single.await('SELECT id FROM mdt_weapons WHERE serial = ? LIMIT 1', { serial }) if existing then @@ -258,11 +199,6 @@ ps.registerCallback(resourceName .. ':server:saveWeaponInfo', function(source, p SET information = ?, owner = ?, weaponClass = ?, weaponModel = ? WHERE serial = ? ]], { notes, owner, weapClass, weapModel, serial }) - - MySQL.insert.await([[ - INSERT INTO mdt_weapon_ownership_history (serial, owner, weapon_model, weapon_class, information, changed_by, reason) - VALUES (?, ?, ?, ?, ?, ?, ?) - ]], { serial, owner, weapModel, weapClass, notes, ps.getIdentifier(src), 'ownership_transfer' }) else MySQL.insert.await([[ INSERT INTO mdt_weapons (serial, scratched, owner, information, weaponClass, weaponModel) diff --git a/server/fivemanage.lua b/server/fivemanage.lua index 80b71280..c1c3a012 100644 --- a/server/fivemanage.lua +++ b/server/fivemanage.lua @@ -140,15 +140,15 @@ ps.registerCallback(resourceName .. ':server:triggerSuspectMugshot', function(so end) -- Upload a profile photo for a suspect via base64 (from MDT UI) -ps.registerCallback(resourceName .. ':server:uploadSuspectPhoto', function(source, citizenid, imageUrl) - if not citizenid or not imageUrl then +ps.registerCallback(resourceName .. ':server:uploadSuspectPhoto', function(source, citizenid, base64Image) + if not citizenid or not base64Image then return { success = false, message = 'Missing data' } end - -- local imageUrl, uploadError = FiveManageUpload(base64Image, 'suspect_' .. citizenid .. '.png') - -- if not imageUrl then - -- return { success = false, message = 'Upload failed: ' .. (uploadError or 'Unknown error') } - -- end + local imageUrl, uploadError = FiveManageUpload(base64Image, 'suspect_' .. citizenid .. '.png') + if not imageUrl then + return { success = false, message = 'Upload failed: ' .. (uploadError or 'Unknown error') } + end -- Ensure profile exists if not EnsureProfileExists(citizenid) then diff --git a/sql/qbcore.sql b/sql/qbcore.sql index 5d85f1cb..e7a47ba4 100644 --- a/sql/qbcore.sql +++ b/sql/qbcore.sql @@ -355,7 +355,7 @@ CREATE TABLE IF NOT EXISTS `mdt_reports_involved` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `reportid` int(10) unsigned NOT NULL, `citizenid` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, - `type` varchar(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, + `type` varchar(7) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, `notes` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL, PRIMARY KEY (`id`), KEY `FK_mdt_reports_involved_mdt_reports` (`reportid`), @@ -553,7 +553,7 @@ CREATE TABLE IF NOT EXISTS `mdt_audit_logs` ( ALTER TABLE `player_vehicles` ADD COLUMN IF NOT EXISTS `mdt_vehicle_information` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL, ADD COLUMN IF NOT EXISTS `mdt_vehicle_points` int(11) NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS `mdt_vehicle_status` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'valid', + ADD COLUMN IF NOT EXISTS `mdt_vehicle_status` enum('valid','suspended','expired','impounded') NOT NULL DEFAULT 'valid', ADD COLUMN IF NOT EXISTS `mdt_vehicle_stolen` tinyint(1) NOT NULL DEFAULT 0, ADD COLUMN IF NOT EXISTS `mdt_vehicle_boloactive` tinyint(1) NOT NULL DEFAULT 0, ADD COLUMN IF NOT EXISTS `mdt_vehicle_image` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL; @@ -566,7 +566,6 @@ CREATE TABLE IF NOT EXISTS `mdt_weapons` ( `information` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL, `weaponClass` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL, `weaponModel` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL, - `flags` JSON DEFAULT NULL, PRIMARY KEY (`id`) USING BTREE, UNIQUE KEY `unique_serial` (`serial`), KEY `FK_mdt_weapons_mdt_profiles` (`owner`), @@ -1324,11 +1323,4 @@ CREATE TABLE IF NOT EXISTS `mdt_warrant_reviews` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; -- Add lawyer_requested column to mdt_reports -ALTER TABLE `mdt_reports` ADD COLUMN IF NOT EXISTS `lawyer_requested` tinyint(1) NOT NULL DEFAULT 0; --- Update mdt_reports_evidence with new columns -ALTER TABLE mdt_reports_evidence - ADD COLUMN IF NOT EXISTS title VARCHAR(255) NOT NULL DEFAULT '' AFTER reportid, - ADD COLUMN IF NOT EXISTS images LONGTEXT NULL DEFAULT NULL AFTER stored; - --- Add flags column to mdt_weapons -ALTER TABLE `mdt_weapons` ADD COLUMN IF NOT EXISTS `flags` JSON DEFAULT NULL; +ALTER TABLE `mdt_reports` ADD COLUMN IF NOT EXISTS `lawyer_requested` tinyint(1) NOT NULL DEFAULT 0; \ No newline at end of file diff --git a/sql/qbx.sql b/sql/qbx.sql index 445e6c8b..3cd9d15a 100644 --- a/sql/qbx.sql +++ b/sql/qbx.sql @@ -355,7 +355,7 @@ CREATE TABLE IF NOT EXISTS `mdt_reports_involved` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `reportid` int(10) unsigned NOT NULL, `citizenid` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, - `type` varchar(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `type` varchar(7) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, `notes` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, PRIMARY KEY (`id`), KEY `FK_mdt_reports_involved_mdt_reports` (`reportid`), @@ -553,7 +553,7 @@ CREATE TABLE IF NOT EXISTS `mdt_audit_logs` ( ALTER TABLE `player_vehicles` ADD COLUMN IF NOT EXISTS `mdt_vehicle_information` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, ADD COLUMN IF NOT EXISTS `mdt_vehicle_points` int(11) NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS `mdt_vehicle_status` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'valid', + ADD COLUMN IF NOT EXISTS `mdt_vehicle_status` enum('valid','suspended','expired','impounded') NOT NULL DEFAULT 'valid', ADD COLUMN IF NOT EXISTS `mdt_vehicle_stolen` tinyint(1) NOT NULL DEFAULT 0, ADD COLUMN IF NOT EXISTS `mdt_vehicle_boloactive` tinyint(1) NOT NULL DEFAULT 0, ADD COLUMN IF NOT EXISTS `mdt_vehicle_image` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL; @@ -566,7 +566,6 @@ CREATE TABLE IF NOT EXISTS `mdt_weapons` ( `information` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, `weaponClass` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, `weaponModel` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, - `flags` JSON DEFAULT NULL, PRIMARY KEY (`id`) USING BTREE, UNIQUE KEY `unique_serial` (`serial`), KEY `FK_mdt_weapons_mdt_profiles` (`owner`), @@ -1324,11 +1323,4 @@ CREATE TABLE IF NOT EXISTS `mdt_warrant_reviews` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- Add lawyer_requested column to mdt_reports -ALTER TABLE `mdt_reports` ADD COLUMN IF NOT EXISTS `lawyer_requested` tinyint(1) NOT NULL DEFAULT 0; --- Update mdt_reports_evidence with new columns -ALTER TABLE mdt_reports_evidence - ADD COLUMN IF NOT EXISTS title VARCHAR(255) NOT NULL DEFAULT '' AFTER reportid, - ADD COLUMN IF NOT EXISTS images LONGTEXT NULL DEFAULT NULL AFTER stored; - --- Add flags column to mdt_weapons -ALTER TABLE `mdt_weapons` ADD COLUMN IF NOT EXISTS `flags` JSON DEFAULT NULL; +ALTER TABLE `mdt_reports` ADD COLUMN IF NOT EXISTS `lawyer_requested` tinyint(1) NOT NULL DEFAULT 0; \ No newline at end of file diff --git a/web/src/components/ContentArea.svelte b/web/src/components/ContentArea.svelte index 043e66f9..e92adcd2 100644 --- a/web/src/components/ContentArea.svelte +++ b/web/src/components/ContentArea.svelte @@ -35,7 +35,6 @@ import LegalDocuments from "../pages/doj/LegalDocuments.svelte"; import type { createInstanceStateService } from "../services/instanceStateService.svelte"; import type { createTabService } from "../services/tabService.svelte"; - import BulletInBoard from "@/pages/BulletInBoard.svelte"; interface Props { authService: AuthService; @@ -110,7 +109,7 @@ citizens: ["citizens_search"], bolos: ["bolos_view", "bolos_create"], vehicles: ["vehicles_search"], - weapons: ["weapons_search", "weapons_add"], + weapons: ["weapons_search"], cases: ["cases_view", "cases_create"], evidence: ["evidence_view", "evidence_create"], reports: ["reports_view", "reports_create"], @@ -120,7 +119,6 @@ bodycams: ["bodycams_view"], ia: ["ia_view"], sop: ["sop_view", "sop_manage"], - bulletin_board: ["bulletin_view"], management: ["management_settings", "management_bulletins", "management_activity", "management_permissions", "management_tracking"], settings: ["management_settings"], }; @@ -149,8 +147,6 @@ cameras: "Cameras", bodycams: "Bodycams", management: "Settings", - sop: "SOP", - bulletin_board: "Bulletin Board", settings: "Preferences", }; return labels[pageId] || pageId; @@ -208,11 +204,11 @@ {:else if activeComponent === "roster"} {:else if activeComponent === "map"} - + {:else if activeComponent === "vehicles"} {:else if activeComponent === "weapons"} - + {:else if activeComponent === "cases"} {:else if String(activeComponent) === "evidence"} @@ -229,8 +225,6 @@ {:else if activeComponent === "sop"} - {:else if activeComponent === "bulletin_board"} - {:else if activeComponent === "management"} {:else if activeComponent === "settings"} diff --git a/web/src/components/InvolvedPersons.svelte b/web/src/components/InvolvedPersons.svelte index 092fd94a..61d695d4 100644 --- a/web/src/components/InvolvedPersons.svelte +++ b/web/src/components/InvolvedPersons.svelte @@ -336,4 +336,4 @@ border: 1px dashed var(--border-color); border-radius: 0.375rem; } - \ No newline at end of file + diff --git a/web/src/components/management/ManagementActivity.svelte b/web/src/components/management/ManagementActivity.svelte index d874d814..370a0608 100644 --- a/web/src/components/management/ManagementActivity.svelte +++ b/web/src/components/management/ManagementActivity.svelte @@ -23,202 +23,108 @@ } const ACTION_LABELS: Record = { - // Authentication - mdt_login: "Logged into MDT", + mdt_login: "Logged into MDT", mdt_logout: "Logged out of MDT", - // Reports report_created: "Created a report", report_updated: "Updated a report", report_deleted: "Deleted a report", - // Warrants warrant_issued: "Issued a warrant", warrant_closed: "Closed a warrant", - // Cases - case_created: "Created a case", - case_updated: "Updated a case", - case_deleted: "Deleted a case", - case_officer_assigned: "Assigned officer to case", - case_officer_removed: "Removed officer from case", - case_attachment_added: "Added case attachment", - case_attachment_removed: "Removed case attachment", - case_attachment_uploaded: "Uploaded case attachment", - // Evidence - evidence_added: "Added evidence", - evidence_updated: "Updated evidence", - evidence_deleted: "Deleted evidence", - evidence_transferred: "Transferred evidence custody", - evidence_image_added: "Added evidence image", - evidence_image_removed: "Removed evidence image", - evidence_linked_case: "Linked evidence to case", - case_created_from_evidence:"Created case from evidence", - // Weapons + case_created: "Created a case", + case_updated: "Updated a case", + case_deleted: "Deleted a case", + case_officer_assigned: "Assigned officer to case", + case_officer_removed: "Removed officer from case", + case_attachment_added: "Added case attachment", + case_attachment_removed: "Removed case attachment", + case_attachment_uploaded: "Uploaded case attachment", + evidence_added: "Added evidence", + evidence_updated: "Updated evidence", + evidence_deleted: "Deleted evidence", + evidence_transferred: "Transferred evidence custody", + evidence_image_added: "Added evidence image", + evidence_image_removed: "Removed evidence image", + evidence_linked_case: "Linked evidence to case", + case_created_from_evidence: "Created case from evidence", weapon_created: "Registered a weapon", weapon_updated: "Updated weapon record", weapon_deleted: "Deleted weapon record", - // Vehicles - vehicle_updated: "Updated vehicle record", - vehicle_impounded: "Impounded a vehicle", - vehicle_released: "Released vehicle from impound", - // Searches + vehicle_updated: "Updated vehicle record", + vehicle_impounded: "Impounded a vehicle", + vehicle_released: "Released vehicle from impound", search_citizens: "Searched citizens", - search_players: "Searched players", + search_players: "Searched players", search_officers: "Searched officers", - // Charges / Fines / Sentencing - fine_processed: "Processed a fine", - charge_updated: "Updated a charge", - arrest_logged: "Logged an arrest", - sent_to_jail: "Sent citizen to jail", - // Officers / Dispatch - callsign_changed: "Changed officer callsign", - signal100_activated: "Activated Signal 100", + fine_processed: "Processed a fine", + charge_updated: "Updated a charge", + arrest_logged: "Logged an arrest", + sent_to_jail: "Sent citizen to jail", + callsign_changed: "Changed officer callsign", + signal100_activated: "Activated Signal 100", signal100_deactivated: "Deactivated Signal 100", - icu_deleted: "Deleted ICU record", - // Cameras / Bodycams - camera_viewed: "Viewed camera footage", - bodycam_viewed: "Viewed bodycam footage", - // ── Patrols ────────────────────────────────────────────────────────── - patrol_created: "Created a patrol", - patrol_deleted: "Deleted a patrol", - patrol_renamed: "Renamed a patrol", - patrol_zone_created: "Drew patrol zone", - patrol_zone_updated: "Updated patrol zone", - patrol_zone_cleared: "Cleared patrol zone", - patrol_officer_assigned: "Assigned officer to patrol", - patrol_officer_removed: "Removed officer from patrol", - patrols_reordered: "Reordered patrols", + icu_deleted: "Deleted ICU record", }; const ACTION_ICONS: Record = { - mdt_login: { icon: "login", color: "#3b82f6" }, + mdt_login: { icon: "login", color: "#3b82f6" }, mdt_logout: { icon: "logout", color: "#6b7280" }, report_created: { icon: "description", color: "#10b981" }, - report_updated: { icon: "edit_note", color: "#f59e0b" }, - report_deleted: { icon: "delete", color: "#ef4444" }, - warrant_issued: { icon: "gavel", color: "#ef4444" }, - warrant_closed: { icon: "check_circle",color: "#10b981" }, - case_created: { icon: "folder_open", color: "#3b82f6" }, - case_updated: { icon: "folder", color: "#f59e0b" }, - case_deleted: { icon: "folder_delete",color: "#ef4444" }, - case_officer_assigned: { icon: "person_add", color: "#10b981" }, - case_officer_removed: { icon: "person_remove", color: "#ef4444" }, - evidence_added: { icon: "inventory_2", color: "#8b5cf6" }, - evidence_updated: { icon: "inventory_2", color: "#f59e0b" }, - evidence_deleted: { icon: "delete", color: "#ef4444" }, - evidence_transferred: { icon: "swap_horiz", color: "#8b5cf6" }, - weapon_created: { icon: "security", color: "#10b981" }, - weapon_updated: { icon: "security", color: "#f59e0b" }, - weapon_deleted: { icon: "no_encryption", color: "#ef4444" }, - vehicle_updated: { icon: "directions_car", color: "#f59e0b" }, - vehicle_impounded: { icon: "local_parking", color: "#ef4444" }, - vehicle_released: { icon: "directions_car", color: "#10b981" }, - fine_processed: { icon: "payments", color: "#f59e0b" }, - charge_updated: { icon: "gavel", color: "#f59e0b" }, - arrest_logged: { icon: "front_hand", color: "#ef4444" }, - sent_to_jail: { icon: "lock", color: "#ef4444" }, - search_citizens: { icon: "person_search",color: "#6b7280" }, - search_players: { icon: "manage_search",color: "#6b7280" }, - search_officers: { icon: "badge", color: "#6b7280" }, - callsign_changed: { icon: "badge", color: "#3b82f6" }, - signal100_activated: { icon: "crisis_alert", color: "#ef4444" }, - signal100_deactivated: { icon: "crisis_alert", color: "#6b7280" }, - camera_viewed: { icon: "videocam", color: "#6b7280" }, - bodycam_viewed: { icon: "camera_alt", color: "#6b7280" }, - // Patrols - patrol_created: { icon: "groups", color: "#38bdf8" }, - patrol_deleted: { icon: "group_remove", color: "#ef4444" }, - patrol_renamed: { icon: "edit", color: "#f59e0b" }, - patrol_zone_created: { icon: "draw", color: "#10b981" }, - patrol_zone_updated: { icon: "draw", color: "#f59e0b" }, - patrol_zone_cleared: { icon: "clear", color: "#ef4444" }, - patrol_officer_assigned: { icon: "person_add", color: "#10b981" }, - patrol_officer_removed: { icon: "person_remove", color: "#ef4444" }, - patrols_reordered: { icon: "swap_vert", color: "#6b7280" }, + report_updated: { icon: "edit_note", color: "#f59e0b" }, + report_deleted: { icon: "delete", color: "#ef4444" }, + warrant_issued: { icon: "gavel", color: "#ef4444" }, + warrant_closed: { icon: "check_circle", color: "#10b981" }, + case_created: { icon: "folder_open", color: "#3b82f6" }, + case_updated: { icon: "folder", color: "#f59e0b" }, + case_deleted: { icon: "folder_delete", color: "#ef4444" }, + evidence_added: { icon: "inventory_2", color: "#8b5cf6" }, + evidence_deleted: { icon: "delete", color: "#ef4444" }, + vehicle_updated: { icon: "directions_car", color: "#f59e0b" }, + vehicle_impounded: { icon: "local_parking", color: "#ef4444" }, + arrest_logged: { icon: "front_hand", color: "#ef4444" }, + fine_processed: { icon: "payments", color: "#f59e0b" }, + search_citizens: { icon: "person_search", color: "#6b7280" }, }; let activities: AuditLog[] = $state([]); - let isLoading = $state(false); - let searchQuery = $state(""); - let currentPage = $state(1); - let totalItems = $state(0); - let perPage = $state(25); + let isLoading = $state(false); + let searchQuery = $state(""); + let currentPage = $state(1); + let totalItems = $state(0); + let perPage = $state(25); let searchTimeout: ReturnType | null = null; let totalPages = $derived(Math.max(1, Math.ceil(totalItems / perPage))); function getActionLabel(action: string): string { - return ACTION_LABELS[action] ?? action.replace(/_/g, " "); + return ACTION_LABELS[action] || action.replace(/_/g, " "); } function getActionIcon(action: string): { icon: string; color: string } { - return ACTION_ICONS[action] ?? { icon: "info", color: "rgba(59, 130, 246, 0.6)" }; - } - - // Parse details JSON and extract the most useful human-readable string. - function getDetailLine(log: AuditLog): string { - if (!log.details) return ""; - let d: Record; - try { d = JSON.parse(log.details); } catch { return ""; } - - // Prefer the pre-built readable label from the server - if (typeof d.action_label === "string") return d.action_label; - - // Patrol-specific fallbacks - if (log.action === "patrol_renamed") { - if (d.patrol_old_name && d.patrol_new_name) - return `"${d.patrol_old_name}" → "${d.patrol_new_name}"`; - } - if (log.action === "patrol_officer_assigned") { - if (d.assigned_name && d.patrol_name) - return `${d.assigned_name} → ${d.patrol_name}`; - } - if (log.action === "patrol_officer_removed") { - if (d.removed_from) return `Removed from "${d.removed_from}"`; - } - if (log.action === "patrol_zone_created" || log.action === "patrol_zone_updated") { - if (d.patrol_name && d.point_count) return `${d.patrol_name} · ${d.point_count} pts`; - } - if (log.action === "patrol_zone_cleared") { - if (d.patrol_name) return `${d.patrol_name}`; - } - if (log.action === "patrols_reordered") { - if (typeof d.new_order === "string") return d.new_order; - } - if (log.action === "patrol_created") { - if (d.patrol_name) return String(d.patrol_name); - } - if (log.action === "patrol_deleted") { - if (d.patrol_name) return String(d.patrol_name); - } - return ""; + return ACTION_ICONS[action] || { icon: "info", color: "rgba(59, 130, 246, 0.6)" }; } function getEntityLabel(log: AuditLog): string { if (!log.entity_id) return ""; const typeLabels: Record = { - profile: "Profile", - report: "Report", - warrant: "Warrant", - case: "Case", - evidence: "Evidence", - vehicle: "Vehicle", - weapon: "Weapon", - citizen: "Citizen", - search: "", - dispatch: "Channel", - fine: "Fine", - charge: "Charge", - arrest: "Arrest", - icu: "ICU", + profile: "Profile", + report: "Report", + warrant: "Warrant", + case: "Case", + evidence: "Evidence", + vehicle: "Vehicle", + weapon: "Weapon", + citizen: "Citizen", + search: "", + dispatch: "Channel", + fine: "Fine", + charge: "Charge", + arrest: "Arrest", + icu: "ICU", case_attachment: "Attachment", - evidence_image: "Image", - officer: "Officer", - mdt_patrol: "Patrol", + evidence_image: "Image", + officer: "Officer", }; - const label = typeLabels[log.entity_type] ?? log.entity_type; - // For patrol actions entity_id is the UUID or 'order' – skip the #prefix for UUIDs - if (log.entity_type === "mdt_patrol") { - return label; - } + const label = typeLabels[log.entity_type] || log.entity_type; return label ? `${label} #${log.entity_id}` : `#${log.entity_id}`; } @@ -226,22 +132,23 @@ if (!value) return "Unknown"; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; - const now = new Date(); + const now = new Date(); const diff = now.getTime() - date.getTime(); const mins = Math.floor(diff / 60000); - if (mins < 1) return "Just now"; + if (mins < 1) return "Just now"; if (mins < 60) return `${mins}m ago`; const hours = Math.floor(mins / 60); if (hours < 24) return `${hours}h ago`; const days = Math.floor(hours / 24); - if (days < 7) return `${days}d ago`; + if (days < 7) return `${days}d ago`; return date.toLocaleDateString() + " " + date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } function formatFullTimestamp(value: string): string { if (!value) return ""; const date = new Date(value); - return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString(); } async function loadActivity(page = 1) { @@ -254,12 +161,12 @@ { items: [], total: 0, page: 1, perPage: 25 }, ); if (response && response.items) { - activities = response.items; - totalItems = response.total || 0; - currentPage = response.page || page; + activities = response.items; + totalItems = response.total || 0; + currentPage = response.page || page; } else if (Array.isArray(response)) { - activities = response as unknown as AuditLog[]; - totalItems = activities.length; + activities = response as unknown as AuditLog[]; + totalItems = activities.length; currentPage = 1; } else { activities = []; @@ -274,21 +181,30 @@ } } + function goToPage(page: number) { + if (page < 1 || page > totalPages || page === currentPage) return; + loadActivity(page); + } + + function handleSearch(e: Event) { const value = (e.target as HTMLInputElement).value; searchQuery = value; if (searchTimeout) clearTimeout(searchTimeout); - searchTimeout = setTimeout(() => { currentPage = 1; loadActivity(1); }, 400); + searchTimeout = setTimeout(() => { + currentPage = 1; + loadActivity(1); + }, 400); } onMount(() => { if (isEnvBrowser()) { activities = [ - { id: 1, actor_name: "D2020 Sgt. J. Miller", action: "patrol_created", entity_type: "mdt_patrol", entity_id: "alpha-1", details: JSON.stringify({ patrol_name: "Alpha-1", patrol_color: "#38bdf8", action_label: 'Created patrol "Alpha-1"' }), created_at: new Date(Date.now() - 60000).toISOString() }, - { id: 2, actor_name: "D2020 Det. R. Santos", action: "patrol_officer_assigned", entity_type: "mdt_patrol", entity_id: "alpha-1", details: JSON.stringify({ patrol_name: "Alpha-1", assigned_name: "J. Miller", action_label: 'Assigned J. Miller to patrol "Alpha-1"' }), created_at: new Date(Date.now() - 300000).toISOString() }, - { id: 3, actor_name: "D2020 Ofc. K. Chen", action: "patrol_zone_created", entity_type: "mdt_patrol", entity_id: "alpha-1", details: JSON.stringify({ patrol_name: "Alpha-1", point_count: 6, action_label: 'Drew zone for patrol "Alpha-1" (6 points)' }), created_at: new Date(Date.now() - 3600000).toISOString() }, - { id: 4, actor_name: "D2020 Lt. A. Brooks", action: "patrol_renamed", entity_type: "mdt_patrol", entity_id: "alpha-1", details: JSON.stringify({ patrol_old_name: "Alpha", patrol_new_name: "Alpha-1", action_label: 'Renamed patrol "Alpha" → "Alpha-1"' }), created_at: new Date(Date.now() - 7200000).toISOString() }, - { id: 5, actor_name: "D2020 Ofc. M. Torres", action: "report_created", entity_type: "report", entity_id: "RPT-042", details: null, created_at: new Date(Date.now() - 86400000).toISOString() }, + { id: 1, actor_name: "D2020 Sgt. J. Miller", action: "report_created", entity_type: "report", entity_id: "RPT-001", details: null, created_at: new Date(Date.now() - 300000).toISOString() }, + { id: 2, actor_name: "D2020 Det. R. Santos", action: "warrant_issued", entity_type: "warrant", entity_id: "5", details: null, created_at: new Date(Date.now() - 3600000).toISOString() }, + { id: 3, actor_name: "D2020 Ofc. K. Chen", action: "case_created", entity_type: "case", entity_id: "42", details: null, created_at: new Date(Date.now() - 7200000).toISOString() }, + { id: 4, actor_name: "D2020 Lt. A. Brooks", action: "mdt_login", entity_type: "profile", entity_id: "BVX67053", details: null, created_at: new Date(Date.now() - 86400000).toISOString() }, + { id: 5, actor_name: "D2020 Ofc. M. Torres", action: "evidence_added", entity_type: "evidence", entity_id: "12", details: null, created_at: new Date(Date.now() - 172800000).toISOString() }, ]; totalItems = 5; return; @@ -317,22 +233,15 @@ {:else}
{#each activities as log (log.id)} - {@const iconData = getActionIcon(log.action)} - {@const detailLine = getDetailLine(log)} - {@const entityLabel= getEntityLabel(log)} + {@const iconData = getActionIcon(log.action)}
-
+
{iconData.icon}
-
- {getActionLabel(log.action)} - {#if entityLabel} - {entityLabel} - {/if} -
- {#if detailLine} - {detailLine} + {getActionLabel(log.action)} + {#if getEntityLabel(log)} + {getEntityLabel(log)} {/if}
@@ -385,8 +294,14 @@ color: rgba(255, 255, 255, 0.8); font-size: 11px; } - .search-input:focus { outline: none; } - .search-input::placeholder { color: rgba(255, 255, 255, 0.2); } + + .search-input:focus { + outline: none; + } + + .search-input::placeholder { + color: rgba(255, 255, 255, 0.2); + } .result-count { color: rgba(255, 255, 255, 0.2); @@ -399,9 +314,19 @@ min-height: 0; overflow-y: auto; } - .activity-list::-webkit-scrollbar { width: 4px; } - .activity-list::-webkit-scrollbar-track { background: transparent; } - .activity-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.06); border-radius: 2px; } + + .activity-list::-webkit-scrollbar { + width: 4px; + } + + .activity-list::-webkit-scrollbar-track { + background: transparent; + } + + .activity-list::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.06); + border-radius: 2px; + } .activity-row { display: flex; @@ -411,34 +336,37 @@ border-bottom: 1px solid rgba(255, 255, 255, 0.03); transition: background 0.1s; } - .activity-row:hover { background: rgba(255, 255, 255, 0.02); } - .activity-row:last-child { border-bottom: none; } + + .activity-row:hover { + background: rgba(255, 255, 255, 0.02); + } + + .activity-row:last-child { + border-bottom: none; + } .activity-icon { - width: 28px; - height: 28px; - min-width: 28px; - border-radius: 5px; + width: 24px; + height: 24px; + min-width: 24px; + border-radius: 3px; display: flex; align-items: center; justify-content: center; } - .activity-icon .material-icons { font-size: 14px; } + + .activity-icon .material-icons { + font-size: 13px; + } .activity-body { display: flex; flex-direction: column; - gap: 2px; + gap: 1px; flex: 1; min-width: 0; } - .activity-top-row { - display: flex; - align-items: center; - gap: 6px; - } - .activity-action { color: rgba(255, 255, 255, 0.8); font-size: 11px; @@ -449,17 +377,6 @@ font-size: 10px; color: rgba(var(--accent-text-rgb), 0.7); font-weight: 500; - background: rgba(var(--accent-rgb), 0.08); - padding: 1px 5px; - border-radius: 3px; - } - - .activity-detail { - font-size: 10px; - color: rgba(255, 255, 255, 0.3); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; } .activity-meta { @@ -469,11 +386,13 @@ gap: 1px; flex-shrink: 0; } + .activity-officer { color: rgba(255, 255, 255, 0.35); font-size: 10px; white-space: nowrap; } + .activity-time { color: rgba(255, 255, 255, 0.2); font-size: 10px; @@ -500,7 +419,7 @@ } @keyframes spin { - 0% { transform: rotate(0deg); } + 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } - \ No newline at end of file + diff --git a/web/src/components/management/ManagementBulletins.svelte b/web/src/components/management/ManagementBulletins.svelte index 5ff805ba..5ed21d69 100644 --- a/web/src/components/management/ManagementBulletins.svelte +++ b/web/src/components/management/ManagementBulletins.svelte @@ -1,638 +1,417 @@ - onDestroy(() => { - window.removeEventListener('mousemove', onGlobalMouseMove); - window.removeEventListener('mouseup', onGlobalMouseUp); - removeGhost(); - }); +
+ {#if statusMessage} +
+ {statusMessage.text} +
+ {/if} - // ── Helpers ──────────────────────────────────────────────── +
+
+ + +
+ +
- function showStatus(text: string, type: 'success' | 'error' = 'success') { - statusMessage = { text, type }; - setTimeout(() => { statusMessage = null; }, 3000); + {#if isLoading} +
+
+

Loading bulletins...

+
+ {:else} +
+ {#each bulletins as bulletin (bulletin.id || bulletin.content)} + {@const parsed = parseBulletin(bulletin.content)} +
+
+ {#if parsed.title && parsed.body} + {parsed.title} +

{parsed.body}

+ {:else} +

{bulletin.content}

+ {/if} +
+ {#if bulletin.id} + + {/if} +
+ {:else} +
No bulletins posted.
+ {/each} +
+ {/if} +
+ + \ No newline at end of file + .delete-btn:hover { + color: rgba(252, 165, 165, 0.8); + background: rgba(239, 68, 68, 0.08); + } + + .empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 200px; + color: rgba(255, 255, 255, 0.35); + font-size: 11px; + } + + .loading-spinner { + width: 24px; + height: 24px; + border: 2px solid rgba(255, 255, 255, 0.06); + border-left: 2px solid rgba(var(--accent-rgb), 0.5); + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin-bottom: 10px; + } + + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + diff --git a/web/src/components/management/ManagementPermissions.svelte b/web/src/components/management/ManagementPermissions.svelte index ebfc1f1f..bade9e00 100644 --- a/web/src/components/management/ManagementPermissions.svelte +++ b/web/src/components/management/ManagementPermissions.svelte @@ -11,7 +11,7 @@ /** Individual permissions hidden for EMS within visible categories */ const EMS_HIDDEN_PERMISSIONS = ['cameras_view', 'management_tracking']; const DOJ_VISIBLE_CATEGORIES = ['citizens', 'cases', 'evidence', 'charges', 'management']; - const DOJ_HIDDEN_PERMISSIONS = ['management_bulletins', 'management_activity', 'management_tags', 'management_tracking', 'management_settings', 'citizens_edit_licenses', 'weapons_add']; + const DOJ_HIDDEN_PERMISSIONS = ['management_bulletins', 'management_activity', 'management_tags', 'management_tracking', 'management_settings', 'citizens_edit_licenses']; let visibleCategories = $derived( jobType === 'ems' diff --git a/web/src/components/report-editor/EvidenceManager.svelte b/web/src/components/report-editor/EvidenceManager.svelte index 3b1288a3..0949dbce 100644 --- a/web/src/components/report-editor/EvidenceManager.svelte +++ b/web/src/components/report-editor/EvidenceManager.svelte @@ -1,20 +1,17 @@ - -{#if addImgOpen} - - - -{/if} - - -{#if lightboxOpen} - - - -{/if} -
- - {#if galleryOpen} - - - - {/if} - - - {#if galleryAddOpen} - - - - {/if} - - - {#if lightboxOpen} - - - - {/if} - - {#if vehicleDetail || vehicleDetailLoading} {/if} - - - {#if propertyDetail || propertyDetailLoading} - - {/if} - {#if showIssueLicenseModal}
{/if} - {:else}
- @@ -1458,7 +1032,14 @@
No citizen records available.
{:else}
- NameCitizen IDPhoneGenderDOBStatsFlags + + Name + Citizen ID + Phone + Gender + DOB + Stats + Flags
{#each filteredCitizens as citizen (citizen.id)} @@ -1506,81 +1087,11 @@ {/if}
+ + diff --git a/web/src/pages/Dashboard.svelte b/web/src/pages/Dashboard.svelte index d2b19541..fd641e26 100644 --- a/web/src/pages/Dashboard.svelte +++ b/web/src/pages/Dashboard.svelte @@ -10,7 +10,6 @@ import { createDispatchService } from "../services/dispatchService.svelte"; import ReportItem from "../components/dashboard/ReportItem.svelte"; import type { PlayerData } from "@/interfaces/IPlayerData"; - interface ActiveBolo { id: number; reportId: string; @@ -53,7 +52,7 @@ dojCourtOrders = Array.isArray(orders) ? orders.slice(0, 5) : []; } - // Services + // Create services const dashboardService = createDashboardService() as ReturnType< typeof createDashboardService > & { @@ -63,119 +62,55 @@ }; const dispatchService = createDispatchService(); - // UI state + // UI state (keep in component since it's view-specific) let expandedDispatch: string | null = $state(null); let reportOpened: number | null = $state(null); + let warrantOpened: number | null = $state(null); - // Pagination + // Pagination for warrants and BOLOs const PAGE_SIZE = 10; let warrantPage = $state(0); let boloPage = $state(0); - let warrantTotalPages = $derived(Math.max(1, Math.ceil((dashboardService.activeWarrants ?? []).length / PAGE_SIZE))); + let warrantTotalPages = $derived(Math.max(1, Math.ceil(dashboardService.activeWarrants.length / PAGE_SIZE))); let boloTotalPages = $derived(Math.max(1, Math.ceil((dashboardService.activeBolos || []).length / PAGE_SIZE))); - let pagedWarrants = $derived((dashboardService.activeWarrants ?? []).slice(warrantPage * PAGE_SIZE, (warrantPage + 1) * PAGE_SIZE)); - let pagedBolos = $derived((dashboardService.activeBolos || []).slice(boloPage * PAGE_SIZE, (boloPage + 1) * PAGE_SIZE)); - - // ── Callsign ── - const EMPTY_CALLSIGN_VALUES = new Set(['', 'NIL', 'NO CALLSIGN', 'NONE', 'NULL']); - - function isValidCallsign(cs: unknown): boolean { - if (cs == null || cs === '') return false; - const upper = String(cs).trim().toUpperCase(); - if (EMPTY_CALLSIGN_VALUES.has(upper)) return false; - if (!upper.startsWith('PD-')) return false; - return upper.length > 3; - } - - let callsignModalOpen = $state(false); - let callsignInput = $state(""); - let callsignSaving = $state(false); - let callsignLoading = $state(true); - let localCallsign = $state(""); - - async function fetchCallsign() { - callsignLoading = true; - const result = await fetchNui<{ callsign?: string }>( - NUI_EVENTS.DASHBOARD.GET_CALLSIGN, - { citizenid: playerData?.citizenid }, - { callsign: '' } - ); - const cs = result?.callsign != null ? String(result.callsign).trim() : ""; - if (isValidCallsign(cs)) { - localCallsign = cs; - } - callsignLoading = false; - } - - let hasCallsign = $derived(isValidCallsign(localCallsign)); - - function openCallsignModal() { - callsignInput = hasCallsign ? localCallsign : "PD-"; - callsignModalOpen = true; - } - - function closeCallsignModal() { - callsignModalOpen = false; - callsignInput = ""; - } - - function handleCallsignInput(e: Event) { - let val = (e.target as HTMLInputElement).value - .toUpperCase() - .replace(/[^A-Z0-9\-]/g, ""); - - if (!val.startsWith('PD-')) { - if (val.startsWith('PD')) { - val = 'PD-' + val.slice(2); - } else if (val.startsWith('P')) { - val = 'PD-' + val.slice(1).replace(/^D-?/, ''); - } else { - val = 'PD-' + val.replace(/^[PD\-]+/, ''); - } - } - callsignInput = val.slice(0, 6); - (e.target as HTMLInputElement).value = callsignInput; - } - - async function saveCallsign() { - const cs = callsignInput.trim(); - if (!isValidCallsign(cs) || callsignSaving) return; - callsignSaving = true; - try { - await fetchNui(NUI_EVENTS.DASHBOARD.SET_CALLSIGN, { callsign: cs, citizenid: playerData?.citizenid }); - localCallsign = cs; - closeCallsignModal(); - } catch { - /* silent */ - } finally { - callsignSaving = false; - } - } + let pagedWarrants = $derived(dashboardService.activeWarrants.slice(warrantPage * PAGE_SIZE, (warrantPage + 1) * PAGE_SIZE)); + let pagedBolos = $derived((dashboardService.activeBolos || []).slice(boloPage * PAGE_SIZE, (boloPage + 1) * PAGE_SIZE)); - onMount(async () => { + onMount(() => { dashboardService.initialize(); dashboardService.startCarouselTimer(); loadDojDashboard(); - await fetchCallsign(); }); onDestroy(() => { dashboardService.destroy(); }); + function viewWarrant(warrantId: string) { + tabService.setActiveTab("Warrants"); + const activeInstance = tabService.getActiveInstance(); + if (activeInstance) { + tabService.setInstanceTab(activeInstance.id, "Warrants"); + } + } + function viewBolo(boloId: number) { tabService.setActiveTab("BOLOs"); const activeInstance = tabService.getActiveInstance(); - if (activeInstance) tabService.setInstanceTab(activeInstance.id, "BOLOs"); + if (activeInstance) { + tabService.setInstanceTab(activeInstance.id, "BOLOs"); + } } function viewReport(reportId: string) { openReportInEditor(reportId); tabService.setActiveTab("Reports"); const activeInstance = tabService.getActiveInstance(); - if (activeInstance) tabService.setInstanceTab(activeInstance.id, "Reports"); + if (activeInstance) { + tabService.setInstanceTab(activeInstance.id, "Reports"); + } } function toggleDispatch(dispatchId: string) { @@ -198,7 +133,9 @@ function getTimeTranslated(time: number): string { const now = Date.now(); let diffInMs = now - time; + if (diffInMs < 0) return "in the future"; + const units = [ { label: "year", ms: 1000 * 60 * 60 * 24 * 365 }, { label: "month", ms: 1000 * 60 * 60 * 24 * 30 }, @@ -207,7 +144,9 @@ { label: "hour", ms: 1000 * 60 * 60 }, { label: "minute", ms: 1000 * 60 }, ]; + const parts = []; + for (const unit of units) { const count = Math.floor(diffInMs / unit.ms); if (count >= 1) { @@ -215,21 +154,34 @@ diffInMs -= count * unit.ms; } } - return parts.length === 0 ? "just now" : parts.join(", ") + " ago"; + + if (parts.length === 0) return "just now"; + + return parts.join(", ") + " ago"; } async function attachYourselfToDispatch(dispatchId: string) { - const result = await dispatchService.attachYourselfToDispatch(dispatchId); - if (result) dashboardService.setRecentDispatches(result); + const result = + await dispatchService.attachYourselfToDispatch(dispatchId); + if (result) { + dashboardService.setRecentDispatches(result); + } } async function detachYourselfFromDispatch(dispatchId: string) { - const result = await dispatchService.detachYourselfFromDispatch(dispatchId); - if (result) dashboardService.setRecentDispatches(result); + const result = + await dispatchService.detachYourselfFromDispatch(dispatchId); + if (result) { + dashboardService.setRecentDispatches(result); + } } function openReport(id: number) { - reportOpened = reportOpened === id ? null : id; + if (reportOpened === id) { + reportOpened = null; + } else { + reportOpened = id; + } } async function loadMoreReports() { @@ -241,7 +193,9 @@ openReportInEditor(reportId); tabService.setActiveTab("Reports"); const activeInstance = tabService.getActiveInstance(); - if (activeInstance) tabService.setInstanceTab(activeInstance.id, "Reports"); + if (activeInstance) { + tabService.setInstanceTab(activeInstance.id, "Reports"); + } } function getCarouselDotOpacity(index: number): number { @@ -250,16 +204,12 @@ } return 0.3; } - - let currentBulletinContent = $derived( - dashboardService.bulletins[dashboardService.currentBulletinIndex]?.content ?? dashboardService.bulletinContent ?? "" - );
+
-
@@ -270,25 +220,16 @@
- -
- - {dashboardService.reportsInfo.totalThisWeek} - 0} class:negative={dashboardService.reportsInfo.changeFromLastWeek < 0}> - {#if dashboardService.reportsInfo.changeFromLastWeek > 0}+{/if}{dashboardService.reportsInfo.changeFromLastWeek} - - + {dashboardService.reportsInfo.totalThisWeek} 0} class:negative={dashboardService.reportsInfo.changeFromLastWeek < 0}>{#if dashboardService.reportsInfo.changeFromLastWeek > 0}+{/if}{dashboardService.reportsInfo.changeFromLastWeek} Reports this week
- -
@@ -298,16 +239,17 @@ Active units
+
- +
{#if dashboardService.bulletins.length > 0} - {currentBulletinContent} + {dashboardService.bulletins[dashboardService.currentBulletinIndex]?.content || dashboardService.bulletinContent} {#if dashboardService.bulletins.length > 1} {/if} - {#if currentBulletinContent} -
{currentBulletinContent}
- {/if} {:else} No active bulletins {/if}
-
- - {#if !callsignLoading} - {#if hasCallsign} - - {:else} -
- - No callsign set - -
- {/if} -
- {/if} +
- +
+ {#if expandedDispatch === dispatch.id}
Attached Units
- - +
{#each dispatch.units as unit} - {dispatchService.getCallSign(unit.metadata?.callsign)} - {unit.charinfo?.firstname} {unit.charinfo?.lastname} + + {dispatchService.getCallSign(unit.metadata?.callsign)} - {unit.charinfo?.firstname} {unit.charinfo?.lastname} + {/each}
@@ -570,204 +511,533 @@ {/if}
- - - {#if callsignModalOpen} - - -
{ if (e.target === e.currentTarget) closeCallsignModal(); }}> - -
- {/if}
\ No newline at end of file + /* ===== Pager ===== */ + .pager { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 6px 16px 10px; + flex-shrink: 0; + } + + .pager-btn { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 4px; + color: rgba(255, 255, 255, 0.35); + cursor: pointer; + transition: all 0.12s; + } + + .pager-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.7); + } + + .pager-btn:disabled { + opacity: 0.2; + cursor: default; + } + + .pager-info { + font-size: 10px; + color: rgba(255, 255, 255, 0.35); + font-weight: 500; + } + + /* ===== Empty & Load More ===== */ + .empty-state { + color: rgba(255, 255, 255, 0.2); + font-size: 11px; + text-align: center; + padding: 24px 0; + } + + .load-more-btn { + background: transparent; + color: rgba(255, 255, 255, 0.35); + border: none; + border-top: 1px solid rgba(255, 255, 255, 0.04); + padding: 8px; + font-size: 11px; + font-weight: 500; + cursor: pointer; + transition: all 0.12s; + text-align: center; + flex-shrink: 0; + } + + .load-more-btn:hover { + color: rgba(255, 255, 255, 0.5); + background: rgba(255, 255, 255, 0.02); + } + + /* ===== Dispatch Items ===== */ + .dispatch-item { + border-radius: 6px; + overflow: hidden; + transition: background 0.1s; + } + + .dispatch-item:hover { + background: rgba(255, 255, 255, 0.02); + } + + .dispatch-item.expanded { + background: rgba(255, 255, 255, 0.03); + } + + .dispatch-btn { + width: 100%; + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + background: none; + border: none; + cursor: pointer; + color: inherit; + font: inherit; + text-align: left; + } + + .priority-bar { + width: 3px; + height: 24px; + border-radius: 2px; + flex-shrink: 0; + } + + .dispatch-detail { + padding: 0 10px 10px 23px; + } + + .dispatch-detail-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 6px; + } + + .detail-label { + color: rgba(255, 255, 255, 0.3); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .dispatch-btns { + display: flex; + gap: 4px; + } + + .d-action-btn { + background: rgba(var(--accent-rgb), 0.1); + color: #60a5fa; + border: 1px solid rgba(var(--accent-rgb), 0.15); + padding: 3px 8px; + border-radius: 4px; + font-size: 10px; + font-weight: 600; + cursor: pointer; + transition: all 0.12s; + } + + .d-action-btn:hover { + background: rgba(var(--accent-rgb), 0.18); + border-color: rgba(var(--accent-rgb), 0.3); + } + + .unit-chips { + display: flex; + flex-wrap: wrap; + gap: 4px; + } + + .unit-chip { + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.5); + padding: 2px 7px; + border-radius: 3px; + font-size: 10px; + font-weight: 500; + } + + .list-item-btn { + display: flex; + flex-direction: column; + gap: 2px; + width: 100%; + padding: 8px 12px; + background: none; + border: none; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + cursor: pointer; + text-align: left; + color: inherit; + } + + .list-item-btn:hover { + background: rgba(255, 255, 255, 0.04); + } + diff --git a/web/src/pages/Evidence.svelte b/web/src/pages/Evidence.svelte index ae75c808..cb91f1e7 100644 --- a/web/src/pages/Evidence.svelte +++ b/web/src/pages/Evidence.svelte @@ -1,6 +1,7 @@ - -{#if sidebarImageUrlModalOpen} - - -{/if} - - -{#if createImageUrlModalOpen} - - -{/if} -
@@ -528,12 +414,15 @@ }} />
- + - + + +
- - + +
Custody Log
{#if custodyEntries.length === 0} @@ -658,26 +561,61 @@
- +
- +
-
Evidence Images
- +
Upload Evidence Image
+
+ { + const input = event.target as HTMLInputElement; + evidenceImageFile = input.files && input.files[0] ? input.files[0] : null; + }} + /> + + +
+ {#if isUploading} +

Uploading image, please wait...

+ {:else if evidenceImageFile} +

+ {evidenceImageFile.name} ({formatBytes(evidenceImageFile.size)}) +

+ {/if} {#if uploadSuccess}

{uploadSuccess}

{/if} - {#if selectedEvidence?.images && selectedEvidence.images.length > 0} - + + + {#if selectedEvidence?.stash_id} +
+
Evidence Stash
+
+ {selectedEvidence.stash_id} + +
+
+ {/if} + + + {#if selectedEvidence?.images && selectedEvidence.images.length > 0} +
+
Evidence Images
+ - {/if} -
- - - {#if selectedEvidence?.stash_id} -
-
Evidence Stash
-
- {selectedEvidence.stash_id} - -
{/if} {:else} @@ -718,7 +642,6 @@
- {#if showCreate}
- +
{#if typeConfig.description}
@@ -787,7 +710,7 @@ {/if}
- +
Location @@ -807,12 +730,37 @@ Evidence is stored / secured {/if} -
Notes
- +
+ Attach Images + { + const input = event.target as HTMLInputElement; + if (input.files) { + createImageFiles = [...createImageFiles, ...Array.from(input.files)]; + } + }} + /> + {#if createImageFiles.length > 0} +
+ {#each createImageFiles as file, i} +
+ {file.name} ({formatBytes(file.size)}) + +
+ {/each} +
+ {/if} +
{#if evidenceError}

{evidenceError}

@@ -825,7 +773,6 @@
{/if} - {#if lightboxUrl} -{/if} - {#if selectedWeapon}
@@ -371,7 +164,7 @@ Scratched {/if} {#each selectedWeapon.flags as flag} - {flag.type} + {flag} {/each}
@@ -409,45 +202,16 @@
{/if} -
-
Flags
- - {#if selectedWeapon.flags?.length} -
+ {#if selectedWeapon.flags && selectedWeapon.flags.length} +
+
Flags
+
{#each selectedWeapon.flags as flag} - - {flag.type} - {#if flag.info} - — {flag.info} - {/if} - - + {flag} {/each}
- {/if} - -
- - { if (e.key === 'Enter') addFlag(); }} - /> -
-
+ {/if}
Ownership History
@@ -460,28 +224,17 @@ {#each weaponHistory as entry}
- - {entry.owner_name ?? entry.owner ?? 'Unknown'} - {#if entry.owner_name && entry.owner} - ({entry.owner}) - {/if} - + {entry.owner || 'Unknown'} - - Model: {entry.weapon_model || ''} + {entry.weapon_model || ''} {entry.weapon_class ? ` · ${entry.weapon_class}` : ''} - {#if entry.information} - - Notes: {entry.information} - {/if}
{new Date(entry.created_at).toLocaleDateString()} {#if entry.reason} {entry.reason} {/if} - {#if entry.changed_by_name ?? entry.changed_by} - logged by {entry.changed_by_name ?? entry.changed_by} - {/if}
{/each} @@ -501,23 +254,17 @@ - {#if canAddWeapon} - - {/if}
- - Weapon - Serial - Owner - Class - Type - Tint - Flags + Weapon + Serial + Owner + Class + Type + Tint + Flags
{#if loading} @@ -527,9 +274,6 @@ {:else} {#each filteredWeapons as weapon} @@ -605,9 +349,6 @@ margin-left: auto; } - .weapon-avatar { width: 28px; height: 28px; border-radius: 50%; background: rgba(255,255,255,0.04); display: grid; place-items: center; overflow: hidden; flex-shrink: 0; } - .weapon-avatar img { width: 100%; height: 100%; object-fit: cover; } - .back-btn { display: flex; align-items: center; @@ -674,10 +415,6 @@ cursor: not-allowed; } - /* Add Weapon button */ - .add-weapon-btn { display: flex; align-items: center; gap: 3px; background: rgba(59,130,246,0.06); border: 1px solid rgba(59,130,246,0.1); border-radius: 3px; padding: 4px 10px; color: rgba(147,197,253,0.7); font-size: 9px; font-weight: 600; cursor: pointer; transition: all 0.12s; text-transform: none; letter-spacing: 0; } - .add-weapon-btn:hover { background: rgba(59,130,246,0.12); color: rgba(147,197,253,0.9); } - /* ===== List Panel ===== */ .list-panel { flex: 1; @@ -690,7 +427,19 @@ border-radius: 0; } - .list-header { display: grid; grid-template-columns: 24px 1.8fr 1fr 1.5fr 0.8fr 0.9fr 0.7fr 1.2fr; gap: 8px; padding: 8px 16px; border-bottom: 1px solid rgba(255,255,255,0.06); color: rgba(255,255,255,0.35); font-size: 9px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; flex-shrink: 0; } + .list-header { + display: grid; + grid-template-columns: 1.8fr 1fr 1.5fr 0.8fr 0.9fr 0.7fr 1.2fr; + gap: 8px; + padding: 8px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.35); + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.6px; + flex-shrink: 0; + } .list-body { flex: 1; @@ -702,8 +451,22 @@ .list-body::-webkit-scrollbar-track { background: transparent; } .list-body::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.06); border-radius: 2px; } - .weapon-row { display: grid; grid-template-columns: 24px 1.8fr 1fr 1.5fr 0.8fr 0.9fr 0.7fr 1.2fr; gap: 8px; padding: 7px 16px; align-items: center; border: none; border-bottom: 1px solid rgba(255,255,255,0.03); background: transparent; cursor: pointer; transition: background 0.1s; width: 100%; text-align: left; font: inherit; color: inherit; } - + .weapon-row { + display: grid; + grid-template-columns: 1.8fr 1fr 1.5fr 0.8fr 0.9fr 0.7fr 1.2fr; + gap: 8px; + padding: 7px 16px; + align-items: center; + border: none; + border-bottom: 1px solid rgba(255, 255, 255, 0.03); + background: transparent; + cursor: pointer; + transition: background 0.1s; + width: 100%; + text-align: left; + font: inherit; + color: inherit; + } .weapon-row:hover { background: rgba(255, 255, 255, 0.02); @@ -835,8 +598,8 @@ } .info-card-icon { - width: 108px; - height: 108px; + width: 36px; + height: 36px; border-radius: 3px; background: rgba(255, 255, 255, 0.03); display: flex; @@ -930,98 +693,12 @@ padding: 16px; } - /* ===== Tags/Flags ===== */ .flags-row { display: flex; gap: 4px; flex-wrap: wrap; } - .tag-remove { - display: flex; - align-items: center; - justify-content: center; - background: transparent; - border: none; - color: inherit; - opacity: 0.4; - cursor: pointer; - padding: 0; - transition: opacity 0.1s; - line-height: 1; - } - - .tag-remove:hover { - opacity: 1; - } - - .flag-add-row { - display: flex; - gap: 6px; - align-items: center; - margin-top: 8px; - } - - .flag-add-row .form-select { - width: 120px; - flex-shrink: 0; - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 3px; - padding: 5px 8px; - color: rgba(255, 255, 255, 0.8); - font-size: 10px; - font-family: inherit; - } - - .flag-add-row .form-select:focus { - outline: none; - border-color: rgba(255, 255, 255, 0.1); - } - - .flag-add-row .form-input { - flex: 1; - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 3px; - padding: 5px 8px; - color: rgba(255, 255, 255, 0.8); - font-size: 11px; - font-family: inherit; - } - - .flag-add-row .form-input:focus { - outline: none; - border-color: rgba(255, 255, 255, 0.1); - } - - .flag-add-row .form-input::placeholder { - color: rgba(255, 255, 255, 0.2); - } - - .add-tag-btn { - background: rgba(16, 185, 129, 0.06); - color: rgba(52, 211, 153, 0.7); - border: 1px solid rgba(16, 185, 129, 0.1); - border-radius: 3px; - padding: 4px 10px; - font-size: 10px; - font-weight: 500; - cursor: pointer; - transition: all 0.1s; - white-space: nowrap; - } - - .add-tag-btn:hover:not(:disabled) { - background: rgba(16, 185, 129, 0.12); - color: rgba(110, 231, 183, 0.9); - } - - .add-tag-btn:disabled { - opacity: 0.3; - cursor: not-allowed; - } - /* ===== History ===== */ .history-list { display: flex; @@ -1077,49 +754,4 @@ color: rgba(255, 255, 255, 0.35); font-size: 10px; } - - .add-weapon-description { font-size: 10px; color: rgba(255,255,255,0.35); line-height: 1.3; overflow: hidden; text-overflow: ellipsis; max-height: 0; opacity: 0; transition: max-height 0.8s ease, opacity 0.8s ease; } - .add-weapon-description.visible { max-height: 40px; opacity: 1; } - - /* Modal shared */ - .modal-backdrop { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 999; } - .modal { background: var(--card-dark-bg); border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 6px; width: min(540px, 92vw); max-height: 85vh; overflow: hidden; display: flex; flex-direction: column; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); } - .modal-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; border-bottom: 1px solid rgba(255, 255, 255, 0.06); } - .modal-header h3 { margin: 0; font-size: 12px; font-weight: 600; color: rgba(255, 255, 255, 0.85); } - .close-btn { display: flex; align-items: center; justify-content: center; background: transparent; color: rgba(255, 255, 255, 0.3); border: 1px solid rgba(255, 255, 255, 0.06); padding: 4px; border-radius: 3px; cursor: pointer; transition: all 0.1s; } - .close-btn:hover { color: rgba(255, 255, 255, 0.7); border-color: rgba(255, 255, 255, 0.1); } - - .modal-body { padding: 14px 16px; overflow-y: auto; } - .modal-top { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; } - .modal-name { color: rgba(255, 255, 255, 0.85); font-size: 14px; font-weight: 700; } - .modal-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 12px; } - .modal-field { display: flex; flex-direction: column; gap: 2px; } - .field-label { color: rgba(255, 255, 255, 0.35); font-size: 9px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; } - .field-value { color: rgba(255, 255, 255, 0.7); font-size: 11px; font-weight: 500; } - .modal-notes { background: transparent; border: none; border-top: 1px solid rgba(255, 255, 255, 0.04); border-radius: 0; padding: 10px 0 0; } - .notes-body { margin: 4px 0 0; font-size: 11px; line-height: 1.5; color: rgba(255, 255, 255, 0.5); } - - .modal-footer { display: flex; justify-content: space-between; align-items: center; gap: 6px; padding: 10px 16px; border-top: 1px solid rgba(255, 255, 255, 0.06); } - .modal-footer-left { display: flex; gap: 6px; } - .modal-footer-right { display: flex; gap: 6px; } - .resolve-btn { display: flex; align-items: center; gap: 4px; background: rgba(34, 197, 94, 0.06); color: rgba(74, 222, 128, 0.7); border: 1px solid rgba(34, 197, 94, 0.1); padding: 4px 10px; border-radius: 3px; font-size: 10px; font-weight: 500; cursor: pointer; transition: all 0.1s; } - .resolve-btn:hover { background: rgba(34, 197, 94, 0.12); color: rgba(74, 222, 128, 0.9); } - .delete-btn { display: flex; align-items: center; gap: 4px; background: transparent; color: rgba(248, 113, 113, 0.5); border: 1px solid rgba(239, 68, 68, 0.1); padding: 4px 10px; border-radius: 3px; font-size: 10px; font-weight: 500; cursor: pointer; transition: all 0.1s; } - .delete-btn:hover { background: rgba(239, 68, 68, 0.08); color: rgba(252, 165, 165, 0.8); } - .action-btn { background: rgba(var(--accent-rgb), 0.06); color: rgba(var(--accent-text-rgb), 0.7); border: 1px solid rgba(var(--accent-rgb), 0.1); padding: 4px 10px; border-radius: 3px; font-size: 10px; font-weight: 500; cursor: pointer; transition: all 0.1s; } - .action-btn:hover { background: rgba(var(--accent-rgb), 0.12); color: rgba(var(--accent-text-rgb), 0.9); } - .cancel-btn { background: transparent; color: rgba(255, 255, 255, 0.4); border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 3px; padding: 4px 10px; font-size: 10px; font-weight: 500; cursor: pointer; transition: all 0.1s; } - .cancel-btn:hover { color: rgba(255, 255, 255, 0.7); border-color: rgba(255, 255, 255, 0.1); } - .primary-btn { background: rgba(16, 185, 129, 0.06); color: rgba(52, 211, 153, 0.7); border: 1px solid rgba(16, 185, 129, 0.1); border-radius: 3px; padding: 4px 12px; font-size: 10px; font-weight: 600; cursor: pointer; transition: all 0.1s; } - .primary-btn:hover { background: rgba(16, 185, 129, 0.12); color: rgba(110, 231, 183, 0.9); } - - /* Form */ - .form-body { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } - .form-group { display: flex; flex-direction: column; gap: 3px; } - .form-full { grid-column: 1 / -1; } - .form-input { background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 3px; padding: 5px 8px; color: rgba(255, 255, 255, 0.8); font-size: 11px; transition: border-color 0.1s; font-family: inherit; } - .form-input:focus { outline: none; border-color: rgba(255, 255, 255, 0.1); } - .form-input::placeholder { color: rgba(255, 255, 255, 0.2); } - .form-select { padding-right: 22px; font-size: 10px; } - textarea.form-input { resize: vertical; min-height: 60px; } diff --git a/web/src/services/collabService.svelte.ts b/web/src/services/collabService.svelte.ts index 968d5693..d2065d0b 100644 --- a/web/src/services/collabService.svelte.ts +++ b/web/src/services/collabService.svelte.ts @@ -4,7 +4,6 @@ import { useNuiEvent } from "../utils/useNuiEvent"; import { NUI_EVENTS } from "../constants/nuiEvents"; import { GetParentResourceName } from "../utils/fivem"; import { isEnvBrowser } from "../utils/misc"; -import { Awareness, encodeAwarenessUpdate, applyAwarenessUpdate, removeAwarenessStates } from "y-protocols/awareness"; export interface CollabEditor { source: number; @@ -59,7 +58,6 @@ export function createCollabService() { let myCitizenId: string = ""; let ydoc: Y.Doc | null = null; - let awareness: Awareness | null = null; let isDestroyed = false; let yjsPollerInterval: ReturnType | null = null; @@ -87,8 +85,6 @@ export function createCollabService() { stopYjsPoller(); yjsPollerInterval = setInterval(async () => { if (!ydoc || isDestroyed || !state.active) return; - - // --- Yjs document updates --- try { const resp = await fetchNui<{ updates: any[] }>( "pollYjsUpdates" as any, @@ -96,66 +92,32 @@ export function createCollabService() { { updates: [] }, 2000, ); - if (resp?.updates && resp.updates.length > 0 && ydoc && !isDestroyed) { - const allDecoded: Uint8Array[] = []; - for (const batch of resp.updates) { - if (batch.updates && Array.isArray(batch.updates)) { - for (const item of batch.updates) { - const b64 = typeof item === "string" ? item : item.update; - if (b64) allDecoded.push(base64ToUint8(b64)); - } - } else if (batch.update) { - allDecoded.push(base64ToUint8(batch.update)); + if (!resp?.updates || resp.updates.length === 0) return; + if (!ydoc || isDestroyed) return; + + const allDecoded: Uint8Array[] = []; + for (const batch of resp.updates) { + if (batch.updates && Array.isArray(batch.updates)) { + for (const item of batch.updates) { + const b64 = typeof item === "string" ? item : item.update; + if (b64) allDecoded.push(base64ToUint8(b64)); } - } - if (allDecoded.length > 0) { - const merged = allDecoded.length === 1 - ? allDecoded[0] - : Y.mergeUpdates(allDecoded); - Y.applyUpdate(ydoc, merged, "remote"); + } else if (batch.update) { + allDecoded.push(base64ToUint8(batch.update)); } } - } catch { - // Ignore poll errors - } + if (allDecoded.length === 0) return; - // --- Awareness (live cursors / presence) --- - try { - const aResp = await fetchNui<{ updates: string[] }>( - NUI_EVENTS.REPORT.POLL_AWARENESS, - {}, - { updates: [] }, - 2000, - ); - if (aResp?.updates?.length && awareness && !isDestroyed) { - for (const b64 of aResp.updates) { - try { - applyAwarenessUpdate(awareness, base64ToUint8(b64), "remote"); - } catch { - // ignore single bad update - } - } - } + const merged = allDecoded.length === 1 + ? allDecoded[0] + : Y.mergeUpdates(allDecoded); + Y.applyUpdate(ydoc, merged, "remote"); } catch { // Ignore poll errors } }, 100); } - function onAwarenessUpdate( - { added, updated, removed }: { added: number[]; updated: number[]; removed: number[] }, - origin: any, - ) { - if (origin === "remote") return; - if (!state.active || !state.reportId || !awareness) return; - const changed = added.concat(updated, removed); - const update = encodeAwarenessUpdate(awareness, changed); - fireNui(NUI_EVENTS.REPORT.SYNC_AWARENESS, { - reportId: state.reportId, - update: uint8ToBase64(update), - }); - } - function stopYjsPoller() { if (yjsPollerInterval) { clearInterval(yjsPollerInterval); @@ -187,15 +149,6 @@ export function createCollabService() { } } onEditorsChanged?.(state.editors); - - // Announce our own cursor so the new joiner sees it immediately - if (awareness && state.active && state.reportId) { - const update = encodeAwarenessUpdate(awareness, [awareness.clientID]); - fireNui(NUI_EVENTS.REPORT.SYNC_AWARENESS, { - reportId: state.reportId, - update: uint8ToBase64(update), - }); - } }); useNuiEvent("reportEditorLeft", (data) => { @@ -227,10 +180,8 @@ export function createCollabService() { lastStructuredData?: Record; }> { ydoc = new Y.Doc(); - awareness = new Awareness(ydoc); isDestroyed = false; ydoc.on("update", onYjsUpdate); - awareness.on("update", onAwarenessUpdate); const result = await fetchNui<{ success: boolean; @@ -240,7 +191,7 @@ export function createCollabService() { lastContent?: string; lastStructuredData?: Record; version?: number; - yjsUpdates?: string[]; + yjsState?: string; }>( NUI_EVENTS.COLLAB.JOIN_REPORT_SESSION, { reportId }, @@ -264,17 +215,12 @@ export function createCollabService() { lastSentStructuredData = {}; onEditorsChanged?.(state.editors); - // Hydrate from the full update history (server returns all accumulated updates) - const updates = (result as any).yjsUpdates as string[] | undefined; - if (updates && updates.length > 0 && ydoc) { + if ((result as any).yjsState && ydoc) { try { - const decoded = updates.map(base64ToUint8); - const merged = decoded.length === 1 - ? decoded[0] - : Y.mergeUpdates(decoded); - Y.applyUpdate(ydoc, merged, "remote"); + const serverState = base64ToUint8((result as any).yjsState); + Y.applyUpdate(ydoc, serverState, "remote"); } catch { - // ignore + // Ignore state apply errors } } } @@ -297,14 +243,6 @@ export function createCollabService() { { success: true }, ); - if (awareness) { - // broadcast removal so others drop our cursor instantly - removeAwarenessStates(awareness, [awareness.clientID], "local"); - awareness.off("update", onAwarenessUpdate); - awareness.destroy(); - awareness = null; - } - if (ydoc) { ydoc.off("update", onYjsUpdate); ydoc.destroy(); @@ -348,9 +286,6 @@ export function createCollabService() { get ydoc() { return ydoc; }, - get awareness() { - return awareness; - }, get myCitizenId() { return myCitizenId; }, @@ -369,4 +304,4 @@ export function createCollabService() { }; } -export type CollabService = ReturnType; \ No newline at end of file +export type CollabService = ReturnType; diff --git a/web/src/services/dashboardService.svelte.ts b/web/src/services/dashboardService.svelte.ts index af4ac01d..81bdaa51 100644 --- a/web/src/services/dashboardService.svelte.ts +++ b/web/src/services/dashboardService.svelte.ts @@ -168,7 +168,7 @@ export function createDashboardService() { useNuiEvent( NUI_EVENTS.DASHBOARD.UPDATE_ACTIVE_WARRANTS, (data) => { - activeWarrants = Array.isArray(data) ? data : activeWarrants; + activeWarrants = data || activeWarrants; }, ); @@ -251,7 +251,8 @@ export function createDashboardService() { { key: NUI_EVENTS.DASHBOARD.GET_ACTIVE_WARRANTS, setter: (value) => { - activeWarrants = Array.isArray(value) ? value : activeWarrants; + activeWarrants = + (value as typeof activeWarrants) || activeWarrants; }, errorMsg: "Failed to fetch active warrants", }, diff --git a/web/src/services/managementService.svelte.ts b/web/src/services/managementService.svelte.ts index 682a57b0..4fefd62d 100644 --- a/web/src/services/managementService.svelte.ts +++ b/web/src/services/managementService.svelte.ts @@ -36,9 +36,9 @@ export function createManagementService() { permissions = [...ALL_PERMISSION_KEYS]; // Dev mock: roles with escalating permissions (labels match whatever the job grades are) const basicPerms = ["citizens_search", "bolos_view", "vehicles_search", "weapons_search", "reports_view", "warrants_view", "charges_view"]; - const midPerms = [...basicPerms, "reports_create", "cases_view", "evidence_view", "bolos_create", "dispatch_attach", "dispatch_route", "map_patrols_view"]; - const seniorPerms = [...midPerms, "cases_create", "cases_edit", "evidence_create", "evidence_transfer", "reports_delete", "warrants_issue", "cameras_view", "bodycams_view", "citizens_edit_licenses", "vehicles_edit_dmv", "weapons_add", "map_patrols_manage",]; - const commandPerms = [...seniorPerms, "cases_delete", "evidence_upload", "warrants_close", "charges_edit", "management_activity", "management_bulletins", "roster_manage_officers", "roster_manage_certifications", "map_patrols_edit"]; + const midPerms = [...basicPerms, "reports_create", "cases_view", "evidence_view", "bolos_create", "dispatch_attach", "dispatch_route"]; + const seniorPerms = [...midPerms, "cases_create", "cases_edit", "evidence_create", "evidence_transfer", "reports_delete", "warrants_issue", "cameras_view", "bodycams_view", "citizens_edit_licenses", "vehicles_edit_dmv"]; + const commandPerms = [...seniorPerms, "cases_delete", "evidence_upload", "warrants_close", "charges_edit", "management_activity", "management_bulletins", "roster_manage_officers", "roster_manage_certifications"]; roles = [ { key: "0", label: "Grade 0", permissions: basicPerms, isBoss: false }, { key: "1", label: "Grade 1", permissions: midPerms, isBoss: false }, diff --git a/web/src/services/reportService.svelte.ts b/web/src/services/reportService.svelte.ts index fe5b9421..87e4c481 100644 --- a/web/src/services/reportService.svelte.ts +++ b/web/src/services/reportService.svelte.ts @@ -85,7 +85,6 @@ export function createReportService() { ...report, id: report.id ?? report.reportId, }; - const response = await fetchNui<{ success: boolean; message?: string; error?: string; reportId?: string }>( NUI_EVENTS.REPORT.SAVE_REPORT, reportToSave, @@ -262,15 +261,19 @@ export function createReportService() { function normalizeInvolved(involved: any): Report["involved"] { const parsed = parseJsonString(involved); - const list = Array.isArray(parsed) ? parsed : Array.isArray(involved) ? involved : []; - - const normalized = { officers: [], suspects: [], victims: [] } as Report["involved"]; - - const VICTIM_TYPES = new Set(["victim", "primary", "secondary", "witness", "complainant"]); + const list = Array.isArray(parsed) + ? parsed + : Array.isArray(involved) + ? involved + : []; + const normalized = { + officers: [], + suspects: [], + victims: [], + } as Report["involved"]; for (const entry of list) { const type = (entry?.type || "").toLowerCase(); - if (type === "officer") { normalized.officers.push({ id: crypto.randomUUID(), @@ -280,13 +283,12 @@ export function createReportService() { type: entry?.type || "Officer", notes: entry?.notes || "", }); - } else if (VICTIM_TYPES.has(type)) { + } else if (type === "victim") { normalized.victims.push({ id: entry?.citizenid || crypto.randomUUID(), citizenid: entry?.citizenid || "", fullName: entry?.name || entry?.fullname || "Unknown", - type: entry?.type || "victim", - notes: entry?.notes || "", + type: entry?.type || "Victim", }); } else { normalized.suspects.push({ @@ -296,7 +298,6 @@ export function createReportService() { notes: entry?.notes || "", warrantActive: entry?.warrantActive || false, profileImage: entry?.image || undefined, - fingerprint: entry?.fingerprint || undefined, }); } } @@ -518,8 +519,8 @@ export function createReportService() { citizenid: victim.citizenid || "", fullName: victim.fullName, type: "Primary", - notes: "", // ← fehlte }; + return { ...report, involved: { @@ -675,4 +676,4 @@ export function createReportService() { }; } -export type ReportService = ReturnType; \ No newline at end of file +export type ReportService = ReturnType;