v0.0.4

Summary

This release improves EMS tablet dispatch, localization, and framework compatibility. lb-tablet can now receive dispatch calls as notifications with Set GPS and Respond actions, Portuguese translations are included, and bridges were expanded for vehicle keys, ESX society funds, modern ESX duty, and Wasabi Ambulance v2 exports.

Review the configuration diffs below before updating — command names, client hooks, state_bags, and tablet settings have changed. The shipped default locale is pt; set Config.locales.lang back to en if you want English.

What's New

Features

  • Portuguese locale (locales/pt.json) and localized complaint, target, dispatch, and laboratory prompts
  • lb-tablet dispatch notifications with Set GPS and Respond buttons, plus Config.external_tablets to load the app and optionally hide the built-in dispatch UI
  • Garage vehicle-key bridge for qbx, okok, qb, cd_garage, gflp10, wasabi_carlock, and MrNewb
  • ESX society / addon-account support for hospital funds
  • okokBossMenu duty toggle and modern ESX setJob(..., onDuty) handling
  • Wasabi Ambulance v2 compatibility exports (RevivePlayer, ApplyHeal)
  • Client hooks are now a returned module: changeState, toggleDeathscreen, and sendDistressSignal
  • AI unit concurrency limit and spawn blacklist zones
  • Per-command enabled flags; HUD editor moved from dispatchsettings to ems:settings
  • Medical Codex pagination and clearer item / vehicle images in the UI

File Changes

⚙️ Configuration & Bridge Files

These files are not encrypted. Review the changes below — you may need to manually apply updates to your server's config files.

bridge/client/tablets.lua

 local ui = uiPage .. (uiPage:find("?", 1, true) and "&" or "?") .. "tablet=true"
 local icon = ("https://cfx-nui-%s/web/images/luxu_medicine_app_logo.png"):format(resourceName)

+local TABLET_SET_GPS_EVENT = Luxu.prefixClient .. "tablet:setDispatchGPS"
+local TABLET_ANSWER_EVENT = Luxu.prefixClient .. "tablet:answerDispatch"
+
 --- Forwards NUI actions into the lb-tablet custom-app iframe while it is open.
 ExternalTabletApp = {
     isOpen = false,
     send = function(action, data)
         if not ExternalTabletApp.isOpen then return end
-        if GetResourceState("lb-tablet") ~= "started" then return end
+        if not IsResourceRunning("lb-tablet") then return end
         exports["lb-tablet"]:SendCustomAppMessage(APP_IDENTIFIER, action, data)
     end,
 }

+---@return boolean
+function ExternalTabletApp.usesNotificationsInsteadOfDispatchUi()
+    return Config.external_tablets.enabled
+        and Config.external_tablets.disable_dispatch
+        and IsResourceRunning("lb-tablet")
+end
+
+---@param call table
+---@param kind? "new"|"air"
+function ExternalTabletApp.notifyDispatchCall(call, kind)
+    if not call or not IsResourceRunning("lb-tablet") then return end
+
+    local title
+    local content
+    if kind == "air" then
+        title = Locales.notify_dispatch_air_support_requested:format(
+            call.caller or Locales.dispatch_air_support_unknown_caller
+        )
+        content = call.location or ""
+    else
+        title = Locales.notify_tablet_new_dispatch
+        local details = call.title or ""
+        if call.code and call.code ~= "" then
+            details = (details ~= "") and (call.code .. " " .. details) or call.code
+        end
+        if details ~= "" and call.location and call.location ~= "" then
+            content = Locales.notify_tablet_dispatch_content:format(details, call.location)
+        else
+            content = details ~= "" and details or (call.location or "")
+        end
+    end
+
+    exports["lb-tablet"]:SendNotification({
+        app = APP_IDENTIFIER,
+        title = title,
+        content = content,
+        dontSaveToDatabase = true,
+        customData = {
+            buttons = {
+                {
+                    title = Locales.dispatch_set_gps_label,
+                    event = TABLET_SET_GPS_EVENT,
+                    data = { coords = call.coords },
+                },
+                {
+                    title = Locales.dispatch_respond,
+                    event = TABLET_ANSWER_EVENT,
+                    data = { callId = call.id },
+                },
+            },
+        },
+    })
+end
+
+RegisterNetEvent(TABLET_SET_GPS_EVENT, function(data)
+    local coords = data and data.coords
+    if not coords then return end
+
+    SetNewWaypoint(coords.x, coords.y)
+    Bridge.client.notify(Locales.notify_title, Locales.notify_dispatch_set_on_gps, 3000, "success")
+end)
+
+RegisterNetEvent(TABLET_ANSWER_EVENT, function(data)
+    if not data or not data.callId then return end
+    Luxu.triggerServerEvent("dispatch:answer", data.callId)
+end)
+
 local function AddApp()
     Wait(500) -- Wait for the AddCustomApp export to be registered

 end

 AddEventHandler("onResourceStart", function(resource)
-    if resource == resourceName and GetResourceState("lb-tablet") == "started" then
+    if not Config.external_tablets.enabled then return end
+
+    if resource == resourceName and IsResourceRunning("lb-tablet") then
         AddApp()
     elseif resource == "lb-tablet" then
         AddApp()

bridge/server/garage.lua

+---@class GarageUtils
+local garage = {}
+
+function garage.giveVehicleKeys(source, netId, plate, modelHash)
+    if GetResourceState("qbx_vehiclekeys") == "started" then
+        return exports.qbx_vehiclekeys:GiveKeys(source, netId)
+    elseif GetResourceState("okokGarage") == "started" then
+        return TriggerEvent("okokGarage:GiveKeys", source, plate)
+    elseif GetResourceState("qb-vehiclekeys") == "started" then
+        return exports.qb_vehiclekeys:GiveKeys(source, plate)
+    elseif GetResourceState("cd_garage") == "started" then
+        return TriggerClientEvent("cd_garage:AddKeys", source, plate)
+    elseif GetResourceState("gflp10-carkeys") == "started" then
+        return exports.gflp10_carkeys:AddCarkey(source, modelHash, plate)
+    elseif GetResourceState("wasabi_carlock") == "started" then
+        return exports.wasabi_carlock:GiveKey(source, plate, false)
+    elseif GetResourceState("MrNewbVehicleKeys") == "started" then
+        return exports.MrNewbVehicleKeys:GiveKeysByPlate(source, plate)
+    end
+
+    --- Fallback
+    TriggerClientEvent("vehiclekeys:client:SetOwner", source, plate)
+end
+
+return garage

bridge/server/player.lua


 ---@param charId string
 function player.getPlayerByCharId(charId)
-    if Framework.name == 'esx' then
+    if Framework.name == "esx" then
         return Framework.object.GetPlayerFromIdentifier(charId)
     else
         return Framework.object.Functions.GetPlayerByCitizenId(charId)

 ---@param charId string
 function player.getOfflinePlayerByCharId(charId)
-    if Framework.name == 'qb' or Framework.name == "qbx" then
+    if Framework.name == "qb" or Framework.name == "qbx" then
         return Framework.object.Functions.GetOfflinePlayerByCitizenId(charId)
     else
         local result = MySQL.single.await("SELECT * FROM `users` WHERE `identifier` = ?", { charId })
         local levelNum = tonumber(level)
         if levelNum and data then
             normalized[levelNum] = {
-                name    = data.name,
-                label   = data.label or data.name,
+                name = data.name,
+                label = data.label or data.name,
                 payment = data.payment or data.salary or 0,
-                isBoss  = data.isboss == true or data.isBoss == true or
+                isBoss = data.isboss == true or data.isBoss == true or
                     (Framework.name == "esx" and lib.table.contains(Config.job.boss_grades, data.name))
             }
         end
         end

         return {
-            name       = job.name,
-            label      = job.label,
-            grade      = job.grade,
-            gradeName  = job.grade_name,
+            name = job.name,
+            label = job.label,
+            grade = job.grade,
+            gradeName = job.grade_name,
             gradeLabel = job.grade_label,
-            onDuty     = onDuty,
-            salary     = job.payment,
-            isBoss     = isBoss
+            onDuty = onDuty,
+            salary = job.payment,
+            isBoss = isBoss
         }
     else
         local job = playerObject.PlayerData.job
         return {
-            name       = job.name,
-            label      = job.label,
-            grade      = job.grade.level,
-            gradeName  = job.grade.name,
+            name = job.name,
+            label = job.label,
+            grade = job.grade.level,
+            gradeName = job.grade.name,
             gradeLabel = job.grade.name,
-            onDuty     = job.onduty,
-            salary     = job.payment,
-            isBoss     = job.isboss == true
+            onDuty = job.onduty,
+            salary = job.payment,
+            isBoss = job.isboss == true
         }
     end
 end
     local playerObject = player.getPlayerObject(source)
     if not playerObject then return false end

+    if GetResourceState("okokBossMenu") == "started" then
+        return exports["okokBossMenu"]:toggleJobDuty(source, onDuty)
+    end
+
     if Framework.name == "qbx" then
         exports.qbx_core:SetJobDuty(source, onDuty)
         return true
     end

     if Framework.name == "esx" then
-        if playerObject.setDuty then
-            playerObject.setDuty(onDuty)
-            return true
+        local job = playerObject.getJob()
+        if not job then return false end
+
+
+        local jobName = job.name or ""
+        if jobName:sub(1, 4) == "off_" then
+            jobName = jobName:sub(5)
         end

-        if playerObject.setJobDuty then
-            playerObject.setJobDuty(onDuty)
+        -- Modern ESX: duty is job.onDuty, set via setJob(name, grade, onDuty).
+        if type(job.onDuty) == "boolean" then
+            playerObject.setJob(jobName, job.grade, onDuty)
             return true
         end

-        local job = playerObject.getJob and playerObject.getJob() or nil
-        if not job then return false end
-
-        local jobName = job.name or ""
-        local targetJob = jobName
-        if onDuty and jobName:sub(1, 4) == "off_" then
-            targetJob = jobName:sub(5)
-        elseif not onDuty and jobName:sub(1, 4) ~= "off_" then
-            targetJob = "off_" .. jobName
+        -- Legacy: off_<job> rename. Invalid on current ESX (job does not exist).
+        local currentName = job.name or ""
+        local targetJob = currentName
+        if onDuty and currentName:sub(1, 4) == "off_" then
+            targetJob = currentName:sub(5)
+        elseif not onDuty and currentName:sub(1, 4) ~= "off_" then
+            targetJob = "off_" .. currentName
         end

-        if targetJob == jobName then return true end
+        if targetJob == currentName then return true end

         if playerObject.setJob then
             playerObject.setJob(targetJob, job.grade)
             return true
         end
-
-        if Framework.object.SetPlayerJob then
-            Framework.object.SetPlayerJob(source, targetJob, job.grade)
-            return true
-        end
     end

     return false
 ---@param reason string
 function player.addMoney(playerObject, account, amount, reason)
     -- Cash as Item ? Missing checks?
-    if account == 'cash' and GetResourceState('ox_inventory') == 'started' then
-        local current = exports.ox_inventory:GetItem(source, 'cash', nil, true) or 0 --[[ @as number ]]
-        exports.ox_inventory:RemoveItem(source, 'cash', current)
-        exports.ox_inventory:AddItem(source, 'cash', current + amount)
+    if account == "cash" and GetResourceState("ox_inventory") == "started" then
+        local current = exports.ox_inventory:GetItem(source, "cash", nil, true) or 0 --[[ @as number ]]
+        exports.ox_inventory:RemoveItem(source, "cash", current)
+        exports.ox_inventory:AddItem(source, "cash", current + amount)
         return
     end

     reason = reason or "luxu_ambulancejob:unknown"

     -- Cash as Item ? Missing checks?
-    if account == 'cash' and GetResourceState('ox_inventory') == 'started' then
-        local current = exports.ox_inventory:GetItem(source, 'cash', nil, true) or 0 --[[ @as number ]]
-        exports.ox_inventory:RemoveItem(source, 'cash', current)
-        exports.ox_inventory:AddItem(source, 'cash', current - amount)
+    if account == "cash" and GetResourceState("ox_inventory") == "started" then
+        local current = exports.ox_inventory:GetItem(source, "cash", nil, true) or 0 --[[ @as number ]]
+        exports.ox_inventory:RemoveItem(source, "cash", current)
+        exports.ox_inventory:AddItem(source, "cash", current - amount)
         return
     end

 ---@return BloodType
 function player.getBloodType(playerObject)
     if Framework.name == "esx" then
-        local bloodType = playerObject.getMeta('bloodtype')
+        local bloodType = playerObject.getMeta("bloodtype")
         if not bloodType then
             bloodType = BLOODTYPES[math.random(1, #BLOODTYPES)]
-            playerObject.setMeta('bloodtype', bloodType)
+            playerObject.setMeta("bloodtype", bloodType)
         end
         return bloodType
     else
 ---@return BloodType
 function player.getBloodType(playerObject)
     if Framework.name == "esx" then
-        local bloodType = playerObject.getMeta('bloodtype')
+        local bloodType = playerObject.getMeta("bloodtype")
         if not bloodType then
             bloodType = BLOODTYPES[math.random(1, #BLOODTYPES)]
-            playerObject.setMeta('bloodtype', bloodType)
+            playerObject.setMeta("bloodtype", bloodType)
         end
         return bloodType
     else

bridge/server/society.lua

 ---@class SocietyFunctions
 local society = {}

+--- Funds live in esx_addonaccount. Resolve the registered account name first.
+---@param job string
+---@return table|nil
+local function getEsxSocietyAccount(job)
+    local society = exports["esx_society"]:GetSociety(job)
+    local accountName = society and society.account or ("society_" .. job)
+    if not IsResourceRunning("esx_addonaccount") then
+        return
+    end
+    return exports["esx_addonaccount"]:GetSharedAccount(accountName)
+end
+
 function society.getMoney(job)
     if IsResourceRunning("Renewed-Banking") then
         return exports["Renewed-Banking"]:getAccountMoney(job)
     if IsResourceRunning("qb-banking") then
         return exports["qb-banking"]:GetAccountBalance(job)
     end
+    if IsResourceRunning("esx_society") then
+        local account = getEsxSocietyAccount(job)
+        return account and account.money or 0
+    end
     return 0
 end

         exports["Renewed-Banking"]:addAccountMoney(job, amount, reason)
     elseif IsResourceRunning("qb-banking") then
         exports["qb-banking"]:AddMoney(job, amount, reason)
+    elseif IsResourceRunning("esx_society") then
+        local account = getEsxSocietyAccount(job)
+        if account then
+            account.addMoney(amount)
+        end
     end
 end

         exports["Renewed-Banking"]:removeAccountMoney(job, amount, reason)
     elseif IsResourceRunning("qb-banking") then
         exports["qb-banking"]:RemoveMoney(job, amount, reason)
+    elseif IsResourceRunning("esx_society") then
+        local account = getEsxSocietyAccount(job)
+        if account then
+            account.removeMoney(amount)
+        end
     end
 end

config/compatibility/server.lua

         StatusServer.revivePlayer(target, true)
     end
 end)
+
+function ExportProvider(resource, exportName, func)
+    AddEventHandler(("__cfx_export_%s_%s"):format(resource, exportName), func)
+end
+
+ExportProvider("wasabi_ambulance_v2", "RevivePlayer", function(serverId)
+    StatusServer.revivePlayer(serverId, true)
+end)
+ExportProvider("wasabi_ambulance_v2", "ApplyHeal", function(serverId, options)
+    StatusServer.revivePlayer(serverId, false)
+end)

config/config.lua

+local state_bags = {
+    --[[ Common State Bags from other frameworks]]
+    dead = "dead",
+    last_standing = "laststanding",
+    thirst = "thirst",
+    hunger = "hunger",
+}
+
 return {
     debug = true,
     dev_mode = true, -- This is used for our development mode, might have issues, don't use it.
     },

     locales = {
-        lang = "en", -- locales/{lang}.json
+        lang = "pt", -- locales/{lang}.json
         code = "en-US",
         symbol_fallback = "$",
         unit_system = "metric", -- "metric" | "imperial"
         debug = false
     },

-    state_bags = {
-        --[[ Luxu Medicine ]]
-        blood_level = "luxu_medicine:blood_level",
-        cold = "luxu_medicine:cold",
-        heart_rate = "luxu_medicine:heart_rate",
-        spo2 = "luxu_medicine:spo2",
-        temperature = "luxu_medicine:temperature",
-    },
+    state_bags = state_bags,

-    unsafe_client_events = {
-        enabled = false,
-        --- Adds backwards compatibility client events for reviving player.
-        --- This can be exploited by cheaters. We can't protect against this, if enabled.
+    --[[ External Tablets like lb-tablet ]]
+    external_tablets = {
+        enabled = true,         --If true, lb-tablet app will be loaded immediately.
+        disable_dispatch = true -- Disables the small dispatch menu, uses tablet notifications instead.
     },

     dependencies = {
         enabled = false,
         health_penalty = 1,
         check_interval = 60, -- seconds
-        state_bags = { "hunger", "thirst" }
+        state_bags = { state_bags.hunger, state_bags.thirst }
     },

     features = {
         diseases = true,
     },

+
+
     --[[ Job ]]
     job = {
         job_name = "ambulance",
             spawn_altitude = 120.0,
             landing_search_radius = 60.0,
         },
+        limits = {
+            max_concurrent_units = 10,
+        },
+
+        --[[ Blacklist zones for AI to not spawn in ]]
+        blacklist_zones = {
+            --[[   { label = "PVP Zone", coords = vector3(185.8, -1040.4, 29.3), radius = 100.0 }, ]]
+        }
     },




     commands = {
-        ["dispatch"] = {
+        ["ems"] = {
             enabled = true,
-            name = "dispatch",
-            description = "Open the dispatch menu",
+            name = "ems",
+            description = "Open the EMS Tablet",
             jobs = { "ambulance" },
             keybind = {
                 enabled = true,
-                default_key = "F10"
+                default_key = "F11"
             }
         },
-        ["dispatchsettings"] = {
-            name = "dispatchsettings",
-            description = "Open the HUD layout editor",
-        },
-        ["mobility"] = {
-            name = "mobility",
-            description = "Toggle prescribed crutch or wheelchair",
-        },
-        ["ems"] = {
-            name = "ems",
-            description = "Open the EMS menu",
+        ["dispatch"] = {
+            enabled = true,
+            name = "dispatch",
+            description = "Open the dispatch menu",
             jobs = { "ambulance" },
             keybind = {
                 enabled = true,
-                default_key = "F11"
+                default_key = "F10"
             }
         },
-        ["patientactions"] = {
-            name = "ems:patient",
-            description = "Open the patient action hotbar",
-            jobs = { "ambulance" },
-        },
         ["guidebook"] = {
+            enabled = true,
             name = "ems:guidebook",
             description = "Open the guidebook",
             jobs = { "ambulance" },
         },
+        ["settings"] = {
+            enabled = true,
+            name = "ems:settings",
+            description = "Open the HUD layout editor",
+        },
         ["config"] = {
+            enabled = true,
             name = "ems:config",
-            description = "Open Luxu Creator (hospital or pharmacy tools)",
+            description = "Open Hospital/Pharmacy/Laboratory creator",
+        },
+        ["mobility"] = {
+            enabled = true,
+            name = "mobility",
+            description = "Toggle prescribed crutch or wheelchair",
         },

+
         --[[ ⭐ Admin Commands ]]
         ["revive"] = {
+            enabled = true,
             name = "revive",
             description = "Revive Player",
             params = {

config/hooks/client.lua


 --[[ Actual Hooks that can prevent actions ]]

-
+--[[
 ---@enum PlayerState
 STATES = {
     ALIVE = "alive",
     DEAD = "dead",
 }

+ ]]

+---@class ClientHooks
+local hooks = {}

+--- Called before the local player's life state changes (alive / last_standing / dead).
 ---@param currentState PlayerState
 ---@param newState PlayerState
-function Hooks.changeState(currentState, newState)
+---@return boolean
+function hooks.changeState(currentState, newState)
+    return true
+end

+--- Called when the deathscreen should open (`true`) or close (`false`).
+--- Return false to hide the built-in overlay and screen effects.
+---@param visible boolean
+---@return boolean
+function hooks.toggleDeathscreen(visible)
+    return true
 end

---- Called when the player dies
+--- Called before an automatic distress signal is sent (last standing or dead).
 ---@return boolean
-function Hooks.sendDistressSignal()
+function hooks.sendDistressSignal()
     return true
 end
+
+return hooks

config/hooks/server.lua

 --AddEventHandler("luxu_medicine:server:player_revived", function(targetSource) end)
 --AddEventHandler("luxu_medicine:server:player_healed", function(targetSource) end)
 --AddEventHandler("luxu_medicine:server:player_injured", function(targetSource) end)
+
+local hooks = {}
+--[[ Todo ]]
+
+return hooks

fxmanifest.lua


 author "luxu-gg <[email protected]>"
 description "Luxu Medicine - Ambulance Job"
-version "0.0.3"
+version "0.0.4"

 dependencies {
     "oxmysql",

[addons]/crutches/fxmanifest.lua

[addons]/iak_wheelchair/fxmanifest.lua

[addons]/medical_props/fxmanifest.lua


Full list of files changed

Change File
Modified bridge/client/tablets.lua
Added bridge/server/garage.lua
Modified bridge/server/player.lua
Modified bridge/server/society.lua
Modified config/compatibility/server.lua
Modified config/config.lua
Modified config/hooks/client.lua
Modified config/hooks/server.lua
Modified data/hospitals.json
Modified data/shops.json
Modified fxmanifest.lua
Modified [addons]/crutches/fxmanifest.lua
Modified [addons]/iak_wheelchair/fxmanifest.lua
Modified [addons]/medical_props/fxmanifest.lua
Modified locales/en.json
Added locales/pt.json
Modified client/cl_main.lua
Modified client/handlers/bootstrap.lua
Modified client/handlers/complaints.lua
Modified client/handlers/placement.lua
Modified client/handlers/targets.lua
Modified client/lib/animations.lua
Modified client/modules/ai.lua
Modified client/modules/conditions/blackout_effects.lua
Modified client/modules/conditions/blood_splash_loop.lua
Modified client/modules/conditions/movement_effects.lua
Modified client/modules/garage.lua
Modified client/modules/laboratory/laboratory.lua
Modified client/modules/mobility.lua
Modified client/modules/status.lua
Modified client/modules/treatment/cpr.lua
Modified client/modules/treatment/defib.lua
Modified client/modules/treatment/patient_hotbar.lua
Modified client/modules/treatment/stretcher.lua
Modified client/modules/treatment/triage.lua
Modified client/modules/vitals.lua
Modified server/features/dispatch.lua
Modified server/features/shops.lua
Modified server/features/treatment.lua
Modified server/modules/conditions.lua
Modified server/modules/garage.lua
Modified server/modules/medical_bag.lua
Modified server/modules/minigames.lua
Modified server/modules/mobility.lua
Modified server/modules/player2player.lua
Modified server/modules/shop.lua
Modified server/modules/viruses.lua
Modified server/modules/vitals.lua
Modified server/sv_main.lua
Modified server/sv_status.lua
Modified shared/init.lua
Modified shared/types.lua
Modified shared/utils.lua
Modified web/