v1.14.7

Summary

This release adds inventory snapshots with restore and delete actions, job/gang society balance viewing and management across many banking resources, a restructured staff logging system with richer metadata, enhanced duty tracking and statistics, Quick Menu reorder settings, and various inventory bridge and clothing fixes.

What's New

Features

  • Inventory snapshots with configurable retention, automatic death-inventory capture, restore, and delete actions
  • Job and gang society balance viewing and management with broad banking and boss menu resource support
  • Restructured staff logs with permission, action, target, and metadata fields for better auditing
  • Enhanced duty tracking, statistics, and Quick Menu action ordering

Bug Fixes

  • Fixed job society balance detection and updates across supported banking resources
  • Fixed 17mov CharacterSystem clothing menu routing for non-ESX frameworks
  • Improved clothing skin reload priority for rcore, qs-appearance, and ak47 clothing resources
  • Fixed Duty tracking issues

Server Hooks

New normalized server hooks for reports, bans, kicks, warns, and jail actions — available via Lua AddEventHandler or the registerHook export. Report payloads now use reportId instead of chat_id.

See Server Hooks for the full hook list, payloads, and examples.


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/clothing.lua

 local clothing = {}

 function clothing.loadPlayerSkin()
-    if GetResourceState("qb-clothing") == "started" then
-        TriggerServerEvent("qb-clothes:loadPlayerSkin")
+    if GetResourceState("rcore_clothing") == "started" or GetResourceState("qs-appearance") == "started" or GetResourceState("ak47_clothing") == "started" or GetResourceState("ak47_qb_clothing") == "started" then
+        ExecuteCommand("reloadskin")
     elseif GetResourceState("illenium-appearance") == "started" then
         local result = lib.callback.await("illenium-appearance:server:getAppearance")
         exports["illenium-appearance"]:setPlayerAppearance(result)
-    elseif GetResourceState("rcore_clothing") == "started" then
-        ExecuteCommand("reloadskin")
+    elseif GetResourceState("qb-clothing") == "started" then
+        TriggerServerEvent("qb-clothes:loadPlayerSkin")
     elseif GetResourceState("skinchanger") == "started" and GetResourceState("esx_skin") == "started" then
         Framework.object.TriggerServerCallback('esx_skin:getPlayerSkin', function(skin)
             TriggerEvent('skinchanger:loadSkin', skin)
         end)
-    elseif GetResourceState("qs-appearance") == "started" or GetResourceState("ak47_clothing") == "started" or GetResourceState("ak47_qb_clothing") == "started" then
-        ExecuteCommand("reloadskin")
     else
         ExecuteCommand("reloadskin")
     end

bridge/server/inventory.lua

     return GetResourceState(resource) == "started"
 end

+local function getItemAmount(item)
+    if type(item) ~= "table" then return 0 end
+
+    local amount = tonumber(item.amount or item.count or item.quantity or 0) or 0
+    if amount <= 0 then return 0 end
+
+    return math.floor(amount)
+end
+
+local function normalizeItems(items)
+    local normalized = {}
+    if type(items) ~= "table" then return normalized end
+
+    for key, item in pairs(items) do
+        if type(item) == "table" and item.name then
+            local amount = getItemAmount(item)
+            if amount > 0 then
+                normalized[#normalized + 1] = {
+                    name = tostring(item.name),
+                    amount = amount,
+                    slot = tonumber(item.slot) or tonumber(key),
+                    metadata = item.metadata or item.info,
+                }
+            end
+        end
+    end
+
+    table.sort(normalized, function(a, b)
+        if a.slot and b.slot then return a.slot < b.slot end
+        if a.slot then return true end
+        if b.slot then return false end
+        return a.name < b.name
+    end)
+
+    return normalized
+end
+
 ---@param source number
 ---@return table<number, table>
 function inventory.getPlayerItems(source)
 end

 ---@param source number
-function inventory.addItem(source, item, amount, metadata)
+function inventory.addItem(source, item, amount, metadata, slot)
     local playerObj = Luxu.player.getPlayerObject(source)
     if not playerObj then return false end

     if isResourceRunning('ox_inventory') then
-        exports.ox_inventory:AddItem(source, item, amount, metadata, nil, nil)
+        exports.ox_inventory:AddItem(source, item, amount, metadata, slot, nil)
     elseif isResourceRunning("origen_inventory") then
-        exports.origen_inventory:AddItem(source, item, amount, nil, metadata)
+        exports.origen_inventory:AddItem(source, item, amount, slot, metadata)
     elseif isResourceRunning('ak47_inventory') then
-        exports['ak47_inventory']:AddItem(source, item, amount, nil, metadata)
+        exports['ak47_inventory']:AddItem(source, item, amount, slot, metadata)
     elseif isResourceRunning('ak47_qb_inventory') then
-        exports['ak47_qb_inventory']:AddItem(source, item, amount, nil, metadata)
+        exports['ak47_qb_inventory']:AddItem(source, item, amount, slot, metadata)
     elseif isResourceRunning('core_inventory') then
         local identifier = Luxu.player.getCharId(playerObj)
         local inventory = 'content-' .. identifier:gsub(':', '')
         exports['core_inventory']:addItem(inventory, item, amount, metadata, "content")
     elseif isResourceRunning('qs-inventory') then
-        exports['qs-inventory']:AddItem(source, item, amount, nil, metadata)
+        exports['qs-inventory']:AddItem(source, item, amount, slot, metadata)
     elseif isResourceRunning("tgiann-inventory") then
         exports["tgiann-inventory"]:AddItem(source, item, amount)
     elseif isResourceRunning("codem-inventory") then
-        exports["codem-inventory"]:AddItem(source, item, amount, nil, metadata)
+        exports["codem-inventory"]:AddItem(source, item, amount, slot, metadata)
     elseif isResourceRunning("jaksam_inventory") then
-        exports['jaksam_inventory']:addItem(source, item, amount, metadata)
+        exports['jaksam_inventory']:addItem(source, item, amount, metadata, slot)
     else
         if Framework.name == "qb" or Framework.name == "qbx" then
-            playerObj.Functions.AddItem(item, amount, nil, metadata)
+            playerObj.Functions.AddItem(item, amount, slot, metadata)
         elseif Framework.name == "esx" then
             local xPlayer = ESX.GetPlayerFromId(source)
             xPlayer.addInventoryItem(item, amount)
     end
 end

+function inventory.clearPlayerItems(source)
+    local playerObj = Luxu.player.getPlayerObject(source)
+    if not playerObj then return false end
+
+    for _, item in ipairs(normalizeItems(inventory.getPlayerItems(source))) do
+        local ok, result = pcall(inventory.removeItem, source, item.name, item.amount, item.slot)
+        if not ok or result == false then
+            return false
+        end
+    end
+
+    return true
+end
+
+function inventory.setPlayerItems(source, items)
+    local playerObj = Luxu.player.getPlayerObject(source)
+    if not playerObj then return false end
+
+    local normalized = normalizeItems(items)
+    if not inventory.clearPlayerItems(source) then
+        return false
+    end
+
+    for _, item in ipairs(normalized) do
+        local ok, result = pcall(inventory.addItem, source, item.name, item.amount, item.metadata, item.slot)
+        if not ok or result == false then
+            return false
+        end
+    end
+
+    return true
+end
+
 ---@return boolean
 function inventory.canCarryItem(source, item, amount)
     local playerObj = Luxu.player.getPlayerObject(source)

bridge/server/player.lua


 function player.openClothingMenu(source)
     if GetResourceState("17mov_CharacterSystem") == "started" then
-        TriggerClientEvent("17mov_CharacterSystem:OpenOutfitsMenu", source)
+        if Framework.name == "esx" then
+            TriggerClientEvent("17mov_CharacterSystem:OpenOutfitsMenu", source)
+        else
+            TriggerClientEvent("qb-clothing:client:openOutfitMenu", source)
+        end
     elseif GetResourceState('illenium-appearance') == "started" then
         TriggerClientEvent("illenium-appearance:client:openClothingShop", source, true)
     elseif GetResourceState('vms_clothestore') == "started" then

bridge/server/society.lua

new file mode 100644
+local JobManager = require 'bridge.server.job'
+
+---@class SocietyManager
+local SocietyManager = {}
+
+local function isResourceStarted(resourceName)
+    return GetResourceState(resourceName) == 'started'
+end
+
+local function hasStartedResource(resourceNames)
+    for _, name in ipairs(resourceNames) do
+        if isResourceStarted(name) then return true end
+    end
+    return false
+end
+
+---@param frameworks string[]|nil
+local function matchesFramework(frameworks)
+    if not frameworks then return true end
+    for _, framework in ipairs(frameworks) do
+        if framework == Framework.name then return true end
+    end
+    return false
+end
+
+local function tableExists(tableName)
+    local ok, count = pcall(function()
+        return MySQL.scalar.await(
+            'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?',
+            { tableName }
+        )
+    end)
+    if not ok then return false end
+    return tonumber(count) > 0
+end
+
+local function normalizeExportBalance(value)
+    if type(value) == 'number' then return value end
+    if type(value) ~= 'table' then return nil end
+
+    for _, key in ipairs({ 'balance', 'amount', 'account_balance', 'money', 'newBalance' }) do
+        if value[key] ~= nil then return tonumber(value[key]) end
+    end
+
+    return nil
+end
+
+local function callResourceExport(resourceName, exportName, ...)
+    local ok, result = pcall(exports[resourceName][exportName], ...)
+    if ok then return result end
+    return nil
+end
+
+---@param resourceNames string[]
+local function getStartedExportResource(resourceNames)
+    for _, name in ipairs(resourceNames) do
+        if isResourceStarted(name) then return name end
+    end
+    return nil
+end
+
+---@param societyName string
+---@return string[]
+local function getSocietyNameVariants(societyName)
+    local rawName = tostring(societyName or ''):match('^%s*(.-)%s*$') or ''
+    local normalizedName = rawName:gsub('^society_', ''):match('^%s*(.-)%s*$') or ''
+    if normalizedName == '' then return {} end
+
+    local seen = {}
+    local names = {}
+
+    local function add(name)
+        if name ~= '' and not seen[name] then
+            seen[name] = true
+            names[#names + 1] = name
+        end
+    end
+
+    add(normalizedName)
+    add(rawName)
+    if not normalizedName:find('^society_') then add('society_' .. normalizedName) end
+
+    return names
+end
+
+---@param accountName string
+---@param prefixed boolean|nil
+---@return string[]
+local function getAccountNameVariants(accountName, prefixed)
+    local names = { accountName }
+    if prefixed then
+        if not accountName:find('^society_') then names[#names + 1] = 'society_' .. accountName end
+        if not accountName:find('^B') then names[#names + 1] = 'B' .. accountName:upper() end
+    end
+    return names
+end
+
+---@return table[]|nil
+local function getSocietyBalanceRowsFromTables()
+    local sources = {
+        {
+            resources = { 'okokBanking' },
+            table = 'okokbanking_societies',
+            query =
+            'SELECT society as name, society_name as label, value as balance FROM okokbanking_societies ORDER BY value DESC',
+        },
+        {
+            resources = { 'tgg-banking' },
+            table = 'tgg_banking_accounts',
+            query =
+            "SELECT ownerId as name, name as label, balance FROM tgg_banking_accounts WHERE type = 'business' ORDER BY balance DESC",
+        },
+        {
+            resources = { 'vms_bossmenu' },
+            table = 'bossmenu',
+            query =
+            "SELECT job as name, account_name as label, balance FROM bossmenu WHERE type IN ('job', 'gang') ORDER BY balance DESC",
+        },
+        {
+            resources = { 'Renewed-Banking', 'renewed-banking', 'renewed_banking', 'qbx_management', 'qbx_core' },
+            frameworks = { 'qb', 'qbx' },
+            table = 'bank_accounts_new',
+            query = 'SELECT id as name, amount as balance FROM bank_accounts_new ORDER BY amount DESC',
+        },
+        {
+            resources = { 'wasabi_banking' },
+            frameworks = { 'qbx' },
+            table = 'bank_accounts_new',
+            query = 'SELECT id as name, amount as balance FROM bank_accounts_new ORDER BY amount DESC',
+        },
+        {
+            resources = { 'qb-banking', 'qb-management', 'wasabi_banking' },
+            frameworks = { 'qb' },
+            table = 'bank_accounts',
+            query =
+            "SELECT account_name as name, account_balance as balance FROM bank_accounts WHERE account_type IN ('job', 'gang') ORDER BY account_balance DESC",
+        },
+        {
+            resources = { 'qb-management', 'dw-bossmenu', 'dw_bossmenu', 'fd_banking', 'fd-banking', 'wasabi_banking' },
+            table = 'management_funds',
+            query =
+            "SELECT job_name as name, amount as balance FROM management_funds WHERE type IN ('boss', 'gang', 'job') ORDER BY amount DESC",
+        },
+        {
+            resources = { 'esx_management', 'esx_society', 'm-Banking', 'm-banking', 'mBanking', 'm_banking' },
+            table = 'addon_account_data',
+            query = [[SELECT REPLACE(account_name, 'society_', '') as name, money as balance
+                FROM addon_account_data
+                WHERE account_name LIKE 'society_%'
+                  AND (owner IS NULL OR owner = '')
+                ORDER BY money DESC
+                LIMIT 100]],
+        },
+        {
+            resources = { 'wasabi_banking' },
+            frameworks = { 'esx' },
+            table = 'addon_account_data',
+            query = [[SELECT REPLACE(account_name, 'society_', '') as name, money as balance
+                FROM addon_account_data
+                WHERE account_name LIKE 'society_%'
+                  AND (owner IS NULL OR owner = '')
+                ORDER BY money DESC
+                LIMIT 100]],
+        },
+    }
+
+    for _, source in ipairs(sources) do
+        if not matchesFramework(source.frameworks) then goto continue end
+        if not hasStartedResource(source.resources) then goto continue end
+        if not tableExists(source.table) then goto continue end
+
+        local ok, rows = pcall(function()
+            return MySQL.query.await(source.query)
+        end)
+        if ok and rows then return rows end
+
+        ::continue::
+    end
+
+    return nil
+end
+
+---@param accounts { name: string, label: string }[]
+---@return table[]|nil
+local function getSocietyBalanceRowsFromExports(accounts)
+    local sources = {
+        {
+            resources = { 'prism_banking', 'prism-banking' },
+            exportResource = 'prism_banking',
+            methods = { 'GetSocietyBalance' },
+        },
+        {
+            resources = { 'kartik-banking' },
+            exportResource = 'kartik-banking',
+            methods = { 'GetAccountMoney' },
+        },
+        {
+            resources = { 'g-bossmenu', 'g-management', 'g-banking', 'g-billing' },
+            exportResource = 'g-bossmenu',
+            methods = { 'GetSocietyMoney' },
+        },
+        {
+            resources = { 'bablo-banking' },
+            exportResource = 'bablo-banking',
+            methods = { 'GetSocietyBalance' },
+            prefixed = true,
+        },
+        {
+            resources = { 'tgg-banking' },
+            exportResource = 'tgg-banking',
+            methods = { 'GetSocietyAccountMoney' },
+        },
+        {
+            resources = { 'fd_banking' },
+            exportResource = 'fd_banking',
+            methods = { 'GetAccount', 'GetGangAccount', 'getBusinessAccount' },
+        },
+    }
+
+    for _, source in ipairs(sources) do
+        local activeResource = getStartedExportResource(source.resources)
+        if not activeResource then goto continue end
+
+        local exportResource = isResourceStarted(source.exportResource) and source.exportResource or activeResource
+        local rows = {}
+
+        for _, account in ipairs(accounts) do
+            local balance = nil
+
+            for _, accountName in ipairs(getAccountNameVariants(account.name, source.prefixed)) do
+                for _, method in ipairs(source.methods) do
+                    balance = normalizeExportBalance(callResourceExport(exportResource, method, accountName))
+                    if balance ~= nil then break end
+                end
+                if balance ~= nil then break end
+            end
+
+            if balance ~= nil then
+                rows[#rows + 1] = { name = account.name, label = account.label, balance = balance }
+            end
+        end
+
+        if #rows > 0 then
+            table.sort(rows, function(a, b) return a.balance > b.balance end)
+            return rows
+        end
+
+        ::continue::
+    end
+
+    return nil
+end
+
+---@param societyName string
+---@param value number
+---@return boolean
+local function updateSocietyBalanceInTables(societyName, value)
+    local balance = tonumber(value)
+    local names = getSocietyNameVariants(societyName)
+    if balance == nil or #names == 0 then return false end
+
+    local placeholders = string.rep('?,', #names):sub(1, -2)
+    local sources = {
+        {
+            resources = { 'okokBanking' },
+            table = 'okokbanking_societies',
+            query = ('UPDATE okokbanking_societies SET value = ? WHERE society IN (%s)'):format(placeholders),
+        },
+        {
+            resources = { 'tgg-banking', 'tgg_banking' },
+            table = 'tgg_banking_accounts',
+            query = ("UPDATE tgg_banking_accounts SET balance = ? WHERE type = 'business' AND ownerId IN (%s)"):format(
+                placeholders),
+        },
+        {
+            resources = { 'vms_bossmenu' },
+            table = 'bossmenu',
+            query = ("UPDATE bossmenu SET balance = ? WHERE type IN ('job', 'gang') AND job IN (%s)"):format(
+                placeholders),
+        },
+        {
+            resources = { 'Renewed-Banking', 'renewed-banking', 'renewed_banking', 'qbx_management', 'qbx_core' },
+            frameworks = { 'qb', 'qbx' },
+            table = 'bank_accounts_new',
+            query = ('UPDATE bank_accounts_new SET amount = ? WHERE id IN (%s)'):format(placeholders),
+        },
+        {
+            resources = { 'wasabi_banking' },
+            frameworks = { 'qbx' },
+            table = 'bank_accounts_new',
+            query = ('UPDATE bank_accounts_new SET amount = ? WHERE id IN (%s)'):format(placeholders),
+        },
+        {
+            resources = { 'qb-banking', 'qb-management', 'wasabi_banking' },
+            frameworks = { 'qb' },
+            table = 'bank_accounts',
+            query = ("UPDATE bank_accounts SET account_balance = ? WHERE account_type IN ('job', 'gang') AND account_name IN (%s)")
+            :format(
+                placeholders),
+        },
+        {
+            resources = { 'qb-management', 'dw-bossmenu', 'dw_bossmenu', 'fd_banking', 'fd-banking', 'wasabi_banking' },
+            table = 'management_funds',
+            query = ("UPDATE management_funds SET amount = ? WHERE type IN ('boss', 'gang', 'job') AND job_name IN (%s)")
+            :format(
+                placeholders),
+        },
+        {
+            resources = { 'esx_management', 'esx_society', 'm-Banking', 'm-banking', 'mBanking', 'm_banking' },
+            table = 'addon_account_data',
+            query = ([[UPDATE addon_account_data
+                SET money = ?
+                WHERE account_name IN (%s)
+                  AND (owner IS NULL OR owner = '')]]):format(placeholders),
+        },
+        {
+            resources = { 'wasabi_banking' },
+            frameworks = { 'esx' },
+            table = 'addon_account_data',
+            query = ([[UPDATE addon_account_data
+                SET money = ?
+                WHERE account_name IN (%s)
+                  AND (owner IS NULL OR owner = '')]]):format(placeholders),
+        },
+    }
+
+    local params = { balance }
+    for _, name in ipairs(names) do
+        params[#params + 1] = name
+    end
+
+    for _, source in ipairs(sources) do
+        if not matchesFramework(source.frameworks) then goto continue end
+        if not hasStartedResource(source.resources) then goto continue end
+        if not tableExists(source.table) then goto continue end
+
+        local ok, affected = pcall(function()
+            return MySQL.update.await(source.query, params)
+        end)
+        if ok and tonumber(affected) > 0 then return true end
+
+        ::continue::
+    end
+
+    return false
+end
+
+--- Returns job/gang society balances from supported banking and boss menu resources.
+---@return { name: string, label: string, balance: number }[]
+function SocietyManager.getJobSocietyBalances()
+    local societyLabels = {}
+    local accounts = {}
+
+    local function addAccount(account)
+        if not account or not account.name or societyLabels[account.name] then return end
+        societyLabels[account.name] = account.label or account.name
+        accounts[#accounts + 1] = { name = account.name, label = societyLabels[account.name] }
+    end
+
+    local ok, jobs = pcall(JobManager.getJobs)
+    if ok and jobs then
+        for _, job in ipairs(jobs) do addAccount(job) end
+    end
+
+    ok, jobs = pcall(JobManager.getGangs)
+    if ok and jobs then
+        for _, gang in ipairs(jobs) do addAccount(gang) end
+    end
+
+    local result = getSocietyBalanceRowsFromTables()
+    if not result then result = getSocietyBalanceRowsFromExports(accounts) end
+    if not result then return {} end
+
+    local balances = {}
+
+    for _, row in ipairs(result) do
+        local rawName = row.name or row.job or row.society or row.account_name or row.job_name
+        if not rawName then goto continue end
+
+        local name = tostring(rawName):gsub('^society_', ''):match('^%s*(.-)%s*$') or ''
+        if name == '' then goto continue end
+
+        local balance = tonumber(row.balance)
+        local item = {
+            name = name,
+            label = row.label or societyLabels[name] or name,
+            balance = balance or 0,
+        }
+
+        local existing = balances[name]
+        if not existing or item.balance > existing.balance then
+            balances[name] = item
+        end
+
+        ::continue::
+    end
+
+    local sorted = {}
+    for _, item in pairs(balances) do
+        sorted[#sorted + 1] = item
+    end
+
+    table.sort(sorted, function(a, b) return a.balance > b.balance end)
+    return sorted
+end
+
+--- Sets a job/gang society balance in supported banking and boss menu tables.
+---@param societyName string
+---@param value number
+---@return boolean
+function SocietyManager.updateSocietyBalance(societyName, value)
+    return updateSocietyBalanceInTables(societyName, value)
+end
+
+return SocietyManager

config/config.json

     "images_url": "https://cfx-nui-qb-inventory/html/images/%s.png",
     "_comment_": "If auto_detect is true, the inventory will be detected automatically. If it is false, you need to specify the inventory resource name."
   },
+  "snapshots": {
+    "retention_days": 3,
+    "cleanup_interval_minutes": 60,
+    "death_inventory": {
+      "enabled": true,
+      "min_delay_seconds": 60
+    }
+  },
   "vehicles": {
     "use_ox_lib_for_mods": false,
     "images_url": "https://docs.fivem.net/vehicles/%s.webp",

config/hooks/server.lua

     }
 end)

---- Uncomment this if you need
+--- Uncomment these if you need

 -- Report Created
 -- AddEventHandler("luxu_admin:server:report_created", function(data)
+--     local chatId = data.reportId --[[@as number]]
 --     local authorSource = data.author_source --[[@as number]]
 --     local authorName = data.author_name --[[@as string]]
 -- end)
+
+-- Player Banned
+-- AddEventHandler("luxu_admin:server:player_banned", function(data)
+--     local banId = data.banId --[[@as number]]
+--     local playerName = data.playerName --[[@as string]]
+--     local reason = data.reason --[[@as string]]
+--     local staffId = data.staffId --[[@as number|nil]]
+--     local staffName = data.staffName --[[@as string|nil]]
+-- end)

config/permissions.json

     "offline_player.update_name": "perm_offline_player_update_name",
     "vehicles.force_wipe": "perm_vehicles_force_wipe",
     "vehicles.view_players_inside": "perm_vehicles_view_players_inside",
-    "map:view_vehicles": "perm_map_view_vehicles"
+    "map:view_vehicles": "perm_map_view_vehicles",
+    "society.view_balance": "perm_society_view_balance",
+    "society.manage_balance": "perm_society_manage_balance"
   }
 }

fxmanifest.lua


 author 'luxu-gg'
 description 'Admin menu for FiveM'
-version '1.14.6'
+version '1.14.7'

 dependencies {
     'oxmysql',

 files { 'config/config.json', 'config/themes.css', 'config/permissions.json', 'config/sounds/*', 'config/help.lua', 'config/constants/*.json', 'config/logging/cl_logs.lua', 'config/teleports.lua', 'config/commands/cl_commands.lua', 'config/custom_functions/client.lua', 'locales/*.json', "web/index.html", 'web/**/*', "bridge/framework.lua", "bridge/shared/**/*.lua", "bridge/client/**/*.lua", "bridge/vrp2/**/*.lua" }

-ui_page 'web/index.html'
---ui_page 'http://localhost:7017/'
+--ui_page 'web/index.html'
+ui_page 'http://localhost:7017'

 escrow_ignore { 'config/**/*', 'bridge/**/*' }

sql/install.sql

   PRIMARY KEY (`id`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

+CREATE TABLE IF NOT EXISTS `luxu_admin_snapshots` (
+  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
+  `type` varchar(64) NOT NULL,
+  `data` longtext NOT NULL,
+  `created_at` datetime NOT NULL DEFAULT current_timestamp(),
+  PRIMARY KEY (`id`),
+  KEY `idx_type_created_at` (`type`, `created_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
 CREATE TABLE IF NOT EXISTS `luxu_admin_player_notes` (
   `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
   `player_id` varchar(255) NOT NULL,

 CREATE TABLE IF NOT EXISTS `luxu_admin_staff_logs` (
   `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
-  `staff_id` int(10) unsigned NOT NULL,
+  `staff_id` int(10) unsigned DEFAULT NULL,
   `staff_name` varchar(255) NOT NULL,
-  `text` text NOT NULL,
+  `permission` varchar(255) DEFAULT NULL,
+  `action` varchar(100) NOT NULL,
+  `target_type` varchar(50) DEFAULT NULL,
+  `target_id` varchar(255) DEFAULT NULL,
+  `target_name` varchar(255) DEFAULT NULL,
+  `metadata` longtext NOT NULL,
   `created_at` datetime NOT NULL DEFAULT current_timestamp(),
   PRIMARY KEY (`id`),
   KEY `idx_staff_created` (`staff_id`, `created_at` DESC),
+  KEY `idx_permission_created` (`permission`, `created_at` DESC),
+  KEY `idx_action_created` (`action`, `created_at` DESC),
+  KEY `idx_target_created` (`target_type`, `target_id`, `created_at` DESC),
   KEY `idx_created` (`created_at` DESC),
-  CONSTRAINT `fk_staff_logs_staff` FOREIGN KEY (`staff_id`) REFERENCES `luxu_admin_group_members` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
+  CONSTRAINT `fk_staff_logs_staff` FOREIGN KEY (`staff_id`) REFERENCES `luxu_admin_group_members` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

 CREATE TABLE IF NOT EXISTS `luxu_admin_player_coords_history` (

Full list of files changed

Change File
Modified bridge/client/clothing.lua
Modified bridge/server/inventory.lua
Modified bridge/server/player.lua
Added bridge/server/society.lua
Modified client/cl_main.lua
Modified client/modules/actions.lua
Modified client/modules/nui.lua
Added client/modules/snapshots.lua
Modified config/config.json
Modified config/hooks/server.lua
Modified config/permissions.json
Modified fxmanifest.lua
Added server/modules/snapshots.lua
Modified server/index.js
Modified server/utils/sv_functions.lua
Modified sql/install.sql
Modified web/