From b7222a1f8f64866fc7ffa2174e85a0775edf23af Mon Sep 17 00:00:00 2001
From: Andyyy7666 <86536434+Andyyy7666@users.noreply.github.com>
Date: Mon, 16 Mar 2026 08:04:08 +0100
Subject: [PATCH] feat: admin group panel
---
client/groupadmin.lua | 32 +++
client/main.lua | 2 +-
database/group_ranks.sql | 10 +
database/groups.sql | 6 +
server/groupadmin.lua | 60 ++++++
server/groups.lua | 226 +++++++++++++++++++
server/main.lua | 10 +-
server/player.lua | 21 +-
ui/index.html | 13 ++
ui/main.js | 283 ++++++++++++++++++++++++
ui/modules/close.js | 46 ++++
ui/modules/fetch.js | 9 +
ui/modules/listener.js | 15 ++
ui/style.css | 455 +++++++++++++++++++++++++++++++++++++++
14 files changed, 1177 insertions(+), 11 deletions(-)
create mode 100644 client/groupadmin.lua
create mode 100644 database/group_ranks.sql
create mode 100644 database/groups.sql
create mode 100644 server/groupadmin.lua
create mode 100644 server/groups.lua
create mode 100644 ui/index.html
create mode 100644 ui/main.js
create mode 100644 ui/modules/close.js
create mode 100644 ui/modules/fetch.js
create mode 100644 ui/modules/listener.js
create mode 100644 ui/style.css
diff --git a/client/groupadmin.lua b/client/groupadmin.lua
new file mode 100644
index 0000000..260e4c3
--- /dev/null
+++ b/client/groupadmin.lua
@@ -0,0 +1,32 @@
+local isOpen = false
+
+RegisterNUICallback("hide", function(_, cb)
+ isOpen = false
+ SetNuiFocus(false, false)
+ cb("ok")
+end)
+
+RegisterNUICallback("groups:create", function(data, cb)
+ local result = lib.callback.await("ND_Core:groups:create", false, data)
+ cb(result or {})
+end)
+
+RegisterNUICallback("groups:edit", function(data, cb)
+ local result = lib.callback.await("ND_Core:groups:edit", false, data)
+ cb(result or {})
+end)
+
+RegisterNUICallback("groups:delete", function(data, cb)
+ local result = lib.callback.await("ND_Core:groups:delete", false, data)
+ cb(result or {})
+end)
+
+RegisterNetEvent("ND:openGroupAdmin", function(groups)
+ if isOpen then return end
+ isOpen = true
+ SetNuiFocus(true, true)
+ SendNUIMessage({
+ type = "groups:open",
+ groups = groups
+ })
+end)
diff --git a/client/main.lua b/client/main.lua
index d7e8069..7a69553 100644
--- a/client/main.lua
+++ b/client/main.lua
@@ -15,7 +15,7 @@ Config = {
randomUnlockedVehicleChance = GetConvarInt("core:randomUnlockedVehicleChance", 30),
requireKeys = GetConvarInt("core:requireKeys", 1) == 1,
useInventoryForKeys = GetConvarInt("core:useInventoryForKeys", 1) == 1,
- groups = json.decode(GetConvar("core:groups", "[]")),
+ groups = {},
compatibility = json.decode(GetConvar("core:compatibility", "[]")),
lockpickTries = GetConvarInt("core:lockpickTries", 3)
}
diff --git a/database/group_ranks.sql b/database/group_ranks.sql
new file mode 100644
index 0000000..a2fb37d
--- /dev/null
+++ b/database/group_ranks.sql
@@ -0,0 +1,10 @@
+CREATE TABLE IF NOT EXISTS `nd_group_ranks` (
+ `id` INT NOT NULL AUTO_INCREMENT,
+ `group_name` VARCHAR(50) NOT NULL,
+ `label` VARCHAR(100) NOT NULL,
+ `weight` INT NOT NULL DEFAULT 1,
+ `isBoss` TINYINT(1) NOT NULL DEFAULT 0,
+ PRIMARY KEY (`id`),
+ INDEX `idx_group_name` (`group_name`),
+ CONSTRAINT `fk_group_ranks_group` FOREIGN KEY (`group_name`) REFERENCES `nd_groups`(`name`) ON DELETE CASCADE ON UPDATE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
diff --git a/database/groups.sql b/database/groups.sql
new file mode 100644
index 0000000..b6aef05
--- /dev/null
+++ b/database/groups.sql
@@ -0,0 +1,6 @@
+CREATE TABLE IF NOT EXISTS `nd_groups` (
+ `name` VARCHAR(50) NOT NULL,
+ `label` VARCHAR(100) NOT NULL,
+ `isJob` TINYINT(1) NOT NULL DEFAULT 0,
+ PRIMARY KEY (`name`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
diff --git a/server/groupadmin.lua b/server/groupadmin.lua
new file mode 100644
index 0000000..213c83a
--- /dev/null
+++ b/server/groupadmin.lua
@@ -0,0 +1,60 @@
+lib.addCommand("groupadmin", {
+ help = "Open the group management admin panel.",
+ restricted = "group.admin"
+}, function(source, args, raw)
+ if not source or source == 0 then return end
+ local groups = NDCore.getAllGroups()
+ TriggerClientEvent("ND:openGroupAdmin", source, groups)
+end)
+
+lib.callback.register("ND_Core:groups:create", function(source, data)
+ local player = NDCore.getPlayer(source)
+ if not player or not player.groups["admin"] then return { success = false } end
+
+ if not data or not data.name or not data.label then
+ return { success = false }
+ end
+
+ local success, err = NDCore.createNewGroup(data.name, data.label, data.isJob, data.ranks)
+ if not success then
+ return { success = false, error = err }
+ end
+
+ return { success = true, groups = NDCore.getAllGroups() }
+end)
+
+lib.callback.register("ND_Core:groups:edit", function(source, data)
+ local player = NDCore.getPlayer(source)
+ if not player or not player.groups["admin"] then return { success = false } end
+
+ if not data or not data.name then
+ return { success = false }
+ end
+
+ local success, err = NDCore.editGroupData(data.name, {
+ label = data.label,
+ isJob = data.isJob,
+ ranks = data.ranks
+ })
+ if not success then
+ return { success = false, error = err }
+ end
+
+ return { success = true, groups = NDCore.getAllGroups() }
+end)
+
+lib.callback.register("ND_Core:groups:delete", function(source, data)
+ local player = NDCore.getPlayer(source)
+ if not player or not player.groups["admin"] then return { success = false } end
+
+ if not data or not data.name then
+ return { success = false }
+ end
+
+ local success = NDCore.deleteGroup(data.name)
+ if not success then
+ return { success = false }
+ end
+
+ return { success = true, groups = NDCore.getAllGroups() }
+end)
diff --git a/server/groups.lua b/server/groups.lua
new file mode 100644
index 0000000..eb6b5fd
--- /dev/null
+++ b/server/groups.lua
@@ -0,0 +1,226 @@
+-- Cache of all groups loaded from DB: Config.groups[name] = { label, isJob, ranks = { [weight] = { id, label, weight, isBoss } } }
+
+local function loadGroupsFromDB()
+ local groups = {}
+ local rows = MySQL.query.await("SELECT * FROM nd_groups")
+ if not rows then return groups end
+
+ for _, row in ipairs(rows) do
+ groups[row.name] = {
+ label = row.label,
+ isJob = row.isJob == 1,
+ ranks = {}
+ }
+ end
+
+ local ranks = MySQL.query.await("SELECT * FROM nd_group_ranks ORDER BY weight ASC")
+ if ranks then
+ for _, rank in ipairs(ranks) do
+ local g = groups[rank.group_name]
+ if g then
+ g.ranks[rank.weight] = {
+ id = rank.id,
+ label = rank.label,
+ weight = rank.weight,
+ isBoss = rank.isBoss == 1
+ }
+ end
+ end
+ end
+
+ return groups
+end
+
+--- Check if a group exists in the database/cache.
+---@param name string
+---@return boolean
+function NDCore.doesGroupExist(name)
+ return Config.groups[name] ~= nil
+end
+
+--- Get group data from cache.
+---@param name string
+---@return table|nil
+function NDCore.getGroupData(name)
+ return Config.groups[name]
+end
+
+--- Create a new group in the database and cache.
+---@param name string Unique key
+---@param label string Display name
+---@param isJob boolean
+---@param ranks table Array of { label = string, weight = number, isBoss = boolean }
+---@return boolean success
+---@return string|nil error
+function NDCore.createNewGroup(name, label, isJob, ranks)
+ if not name or name == "" then return false, "name is required" end
+ if Config.groups[name] then return false, "group already exists" end
+
+ MySQL.insert.await("INSERT INTO nd_groups (`name`, `label`, `isJob`) VALUES (?, ?, ?)", {
+ name, label or name, isJob and 1 or 0
+ })
+
+ Config.groups[name] = {
+ label = label or name,
+ isJob = isJob or false,
+ ranks = {}
+ }
+
+ if ranks and #ranks > 0 then
+ for _, rank in ipairs(ranks) do
+ local id = MySQL.insert.await("INSERT INTO nd_group_ranks (`group_name`, `label`, `weight`, `isBoss`) VALUES (?, ?, ?, ?)", {
+ name, rank.label, rank.weight, rank.isBoss and 1 or 0
+ })
+ Config.groups[name].ranks[rank.weight] = {
+ id = id,
+ label = rank.label,
+ weight = rank.weight,
+ isBoss = rank.isBoss or false
+ }
+ end
+ end
+
+ NDCore.syncGroupsToClients()
+ return true
+end
+
+--- Edit an existing group in the database and cache.
+---@param name string Group key
+---@param data table { label?, isJob?, ranks? }
+---@return boolean success
+---@return string|nil error
+function NDCore.editGroupData(name, data)
+ if not name or not Config.groups[name] then return false, "group does not exist" end
+
+ if data.label ~= nil or data.isJob ~= nil then
+ local label = data.label or Config.groups[name].label
+ local isJob = data.isJob ~= nil and data.isJob or Config.groups[name].isJob
+ MySQL.update.await("UPDATE nd_groups SET `label` = ?, `isJob` = ? WHERE `name` = ?", {
+ label, isJob and 1 or 0, name
+ })
+ Config.groups[name].label = label
+ Config.groups[name].isJob = isJob
+ end
+
+ if data.ranks then
+ -- Delete all old ranks and replace with new set
+ MySQL.update.await("DELETE FROM nd_group_ranks WHERE `group_name` = ?", { name })
+ Config.groups[name].ranks = {}
+
+ for _, rank in ipairs(data.ranks) do
+ local id = MySQL.insert.await("INSERT INTO nd_group_ranks (`group_name`, `label`, `weight`, `isBoss`) VALUES (?, ?, ?, ?)", {
+ name, rank.label, rank.weight, rank.isBoss and 1 or 0
+ })
+ Config.groups[name].ranks[rank.weight] = {
+ id = id,
+ label = rank.label,
+ weight = rank.weight,
+ isBoss = rank.isBoss or false
+ }
+ end
+ end
+
+ NDCore.syncGroupsToClients()
+ return true
+end
+
+--- Delete a group from the database and cache.
+---@param name string
+---@return boolean
+function NDCore.deleteGroup(name)
+ if not name or not Config.groups[name] then return false end
+ MySQL.update.await("DELETE FROM nd_groups WHERE `name` = ?", { name })
+ Config.groups[name] = nil
+ NDCore.syncGroupsToClients()
+ return true
+end
+
+--- Get all groups formatted for UI or external use.
+---@return table
+function NDCore.getAllGroups()
+ local result = {}
+ for name, group in pairs(Config.groups) do
+ local ranks = {}
+ for weight, rank in pairs(group.ranks) do
+ ranks[#ranks+1] = {
+ id = rank.id,
+ label = rank.label,
+ weight = rank.weight,
+ isBoss = rank.isBoss
+ }
+ end
+ table.sort(ranks, function(a, b) return a.weight < b.weight end)
+ result[#result+1] = {
+ name = name,
+ label = group.label,
+ isJob = group.isJob,
+ ranks = ranks
+ }
+ end
+ table.sort(result, function(a, b) return a.name < b.name end)
+ return result
+end
+
+--- Sync groups convar to all clients after changes.
+function NDCore.syncGroupsToClients()
+ -- Build a simplified map for the convar (backwards compatible format)
+ local simplified = {}
+ for name, group in pairs(Config.groups) do
+ local rankLabels = {}
+ local sortedRanks = {}
+ for weight, rank in pairs(group.ranks) do
+ sortedRanks[#sortedRanks+1] = rank
+ end
+ table.sort(sortedRanks, function(a, b) return a.weight < b.weight end)
+ for _, rank in ipairs(sortedRanks) do
+ rankLabels[#rankLabels+1] = rank.label
+ end
+ simplified[name] = {
+ label = group.label,
+ isJob = group.isJob,
+ ranks = rankLabels
+ }
+ end
+ SetConvarReplicated("core:groups", json.encode(simplified))
+end
+
+--- Load groups from DB into Config.groups. Called at startup.
+function NDCore.loadGroups()
+ Config.groups = loadGroupsFromDB()
+ NDCore.syncGroupsToClients()
+end
+
+--- Seed the database from the old JSON config if tables are empty.
+function NDCore.seedGroupsFromJson()
+ local count = MySQL.scalar.await("SELECT COUNT(*) FROM nd_groups")
+ if count and count > 0 then return false end
+
+ local groupsJson = lib.loadJson("_config.groups") or json.decode(GetConvar("core:groups", "[]"))
+ if not groupsJson then return false end
+
+ for name, group in pairs(groupsJson) do
+ MySQL.insert.await("INSERT INTO nd_groups (`name`, `label`, `isJob`) VALUES (?, ?, ?)", {
+ name, group.label or name, (group.isJob and 1 or 0)
+ })
+
+ if group.ranks then
+ for i, rankLabel in ipairs(group.ranks) do
+ local isBoss = false
+ if group.minimumBossRank and i >= group.minimumBossRank then
+ isBoss = true
+ end
+ MySQL.insert.await("INSERT INTO nd_group_ranks (`group_name`, `label`, `weight`, `isBoss`) VALUES (?, ?, ?, ?)", {
+ name, rankLabel, i, isBoss and 1 or 0
+ })
+ end
+ end
+ end
+
+ return true
+end
+
+for name, func in pairs(NDCore) do
+ if type(func) == "function" then
+ exports(name, func)
+ end
+end
diff --git a/server/main.lua b/server/main.lua
index 8784f9d..209355b 100644
--- a/server/main.lua
+++ b/server/main.lua
@@ -4,6 +4,7 @@ NDCore.players = {}
PlayersInfo = {}
local resourceName = GetCurrentResourceName()
local tempPlayersInfo = {}
+-- Groups are now loaded from the database in MySQL.ready via NDCore.loadGroups()
Config = {
serverName = GetConvar("core:serverName", "Unconfigured ND-Core Server"),
@@ -23,7 +24,7 @@ Config = {
randomUnlockedVehicleChance = GetConvarInt("core:randomUnlockedVehicleChance", 30),
disableVehicleAirControl = GetConvarInt("core:disableVehicleAirControl", 1) == 1,
useInventoryForKeys = GetConvarInt("core:useInventoryForKeys", 1) == 1,
- groups = json.decode(GetConvar("core:groups", "[]")),
+ groups = {},
admins = json.decode(GetConvar("core:admins", "[]")),
adminDiscordRoles = json.decode(GetConvar("core:adminDiscordRoles", "[]")),
groupRoles = json.decode(GetConvar("core:groupRoles", "[]")),
@@ -253,8 +254,13 @@ MySQL.ready(function()
"database/users.sql",
"database/characters.sql",
"database/vehicles.sql",
- "database/moneylogs.sql"
+ "database/moneylogs.sql",
+ "database/groups.sql",
+ "database/group_ranks.sql"
}, resourceName)
+ Wait(200)
+ NDCore.seedGroupsFromJson()
+ NDCore.loadGroups()
end)
-- Hourly cron, purge soft-deleted characters older than 30 days.
diff --git a/server/player.lua b/server/player.lua
index 4a215d6..b22e214 100644
--- a/server/player.lua
+++ b/server/player.lua
@@ -404,11 +404,17 @@ local function createCharacterTable(info)
---@return boolean
function self.addGroup(name, rank, customGroup, isJob)
local groupRank = tonumber(rank) or 1
- local groupInfo = lib.table.deepclone(Config.groups?[name] or {})
- local bossRank = groupInfo?.minimumBossRank
+ local groupInfo = Config.groups?[name] or {}
+ local rankData = groupInfo?.ranks?[groupRank]
- for k, v in pairs(customGroup or {}) do
- groupInfo[k] = v
+ local groupLabel = groupInfo?.label or name
+ local rankName = rankData?.label or groupRank
+ local isBoss = rankData?.isBoss or false
+
+ if customGroup then
+ if customGroup.label then groupLabel = customGroup.label end
+ if customGroup.rankName then rankName = customGroup.rankName end
+ if customGroup.isBoss ~= nil then isBoss = customGroup.isBoss end
end
if isJob then
@@ -419,12 +425,11 @@ local function createCharacterTable(info)
self.groups[name] = {
name = name,
- label = groupInfo?.label or name,
- rankName = groupInfo?.ranks?[groupRank] or groupRank,
+ label = groupLabel,
+ rankName = rankName,
rank = groupRank,
isJob = isJob,
- isBoss = bossRank and groupRank >= bossRank,
- metadata = groupInfo.metadata or {}
+ isBoss = isBoss
}
if not isJob then
diff --git a/ui/index.html b/ui/index.html
new file mode 100644
index 0000000..707edee
--- /dev/null
+++ b/ui/index.html
@@ -0,0 +1,13 @@
+
+
+