diff --git a/client/main.lua b/client/main.lua index db6563b..7756f2f 100644 --- a/client/main.lua +++ b/client/main.lua @@ -32,7 +32,7 @@ local function findPlayers() for _, v in pairs(closePlayers) do v.id = GetPlayerServerId(v.id) end - return lib.callback.await('qbx_management:server:getPlayers', false, closePlayers) + return lib.callback.await('qbx_management:server:getPlayers', false, closePlayers) end -- Presents a menu to manage a specific employee including changing grade or firing them @@ -84,13 +84,21 @@ local function employeeList(groupType) local groupName = QBX.PlayerData[groupType].name local employees = lib.callback.await('qbx_management:server:getEmployees', false, groupName, groupType) for _, employee in pairs(employees) do - employeesMenu[#employeesMenu + 1] = { + local employeesData = { title = employee.name, description = groupType == 'job' and JOBS[groupName].grades[employee.grade].name or GANGS[groupName].grades[employee.grade].name, onSelect = function() manageEmployee(employee, groupName, groupType) end, } + if employee.hours and employee.last_checkin then + employeesData.metadata = { + { label = locale('menu.employee_status'), value = employee.onduty and locale('menu.on_duty') or locale('menu.off_duty') }, + { label = locale('menu.hours_in_days'), value = employee.hours }, + { label = locale('menu.last_checkin'), value = employee.last_checkin }, + } + end + employeesMenu[#employeesMenu + 1] = employeesData end lib.registerContext({ diff --git a/config/server.lua b/config/server.lua index 80ca57f..3f4d39a 100644 --- a/config/server.lua +++ b/config/server.lua @@ -1,5 +1,7 @@ return { discordWebhook = nil, -- Replace nil with your webhook if you chose to use discord logging over ox_lib logging + minOnDutyLogTimeMinutes = 30, + formatDateTime = '%m-%d-%Y %H:%M', -- While the config boss menu creation still works, it is recommended to use the runtime export instead. ---@alias GroupName string diff --git a/fxmanifest.lua b/fxmanifest.lua index 3f3590e..28e1b58 100644 --- a/fxmanifest.lua +++ b/fxmanifest.lua @@ -18,7 +18,9 @@ client_scripts { } server_scripts { + '@oxmysql/lib/MySQL.lua', 'server/main.lua', + 'server/storage.lua', } files { @@ -27,4 +29,4 @@ files { } lua54 'yes' -use_experimental_fxv2_oal 'yes' +use_experimental_fxv2_oal 'yes' \ No newline at end of file diff --git a/locales/da.json b/locales/da.json index a802050..0fc7dca 100644 --- a/locales/da.json +++ b/locales/da.json @@ -16,7 +16,12 @@ "gang_menu": "Bande Menu", "boss_menu": "Boss Menu", "gang_management": "[E] - Åbn Bande menu", - "boss_management": "[E] - Åbn Boss menu" + "boss_management": "[E] - Åbn Boss menu", + "hours_in_days": "Hours worked in 30 days", + "last_checkin": "Last check in", + "employee_status": "Employee status", + "on_duty": "On Duty", + "off_duty": "Off Duty" }, "error": { "cant_promote": "Du kan ikke ændre graden for en person, hvis egen grad er større eller lig med din...", diff --git a/locales/de.json b/locales/de.json index cdd998f..cf9e056 100644 --- a/locales/de.json +++ b/locales/de.json @@ -16,7 +16,12 @@ "gang_menu": "Gangmenü", "boss_menu": "Bossmenü", "gang_management": "[E] - Gangmanagement öffnen", - "boss_management": "[E] - Bossmanagement öffnen" + "boss_management": "[E] - Bossmanagement öffnen", + "hours_in_days": "Hours worked in 30 days", + "last_checkin": "Last check in", + "employee_status": "Employee status", + "on_duty": "On Duty", + "off_duty": "Off Duty" }, "error": { "cant_promote": "Du kannst nicht von jemandem den Rang ändern der gleich oder höher ist, als du...", diff --git a/locales/en.json b/locales/en.json index 6e3d577..617bb8e 100644 --- a/locales/en.json +++ b/locales/en.json @@ -16,7 +16,12 @@ "gang_menu": "Gang Menu", "boss_menu": "Boss Menu", "gang_management": "[E] - Open Gang Management", - "boss_management": "[E] - Open Boss Management" + "boss_management": "[E] - Open Boss Management", + "hours_in_days": "Hours worked in 30 days", + "last_checkin": "Last check in", + "employee_status": "Employee status", + "on_duty": "On Duty", + "off_duty": "Off Duty" }, "error": { "cant_promote": "You can't change the grade of a person who's own grade is greater or equal to yours...", diff --git a/locales/es.json b/locales/es.json index 9b6a30f..f548ac6 100644 --- a/locales/es.json +++ b/locales/es.json @@ -16,7 +16,12 @@ "gang_menu": "Menú de la Banda", "boss_menu": "Menú del Jefe", "gang_management": "[E] - Abrir Gestión de la Banda", - "boss_management": "[E] - Abrir Gestión del Jefe" + "boss_management": "[E] - Abrir Gestión del Jefe", + "hours_in_days": "Hours worked in 30 days", + "last_checkin": "Last check in", + "employee_status": "Employee status", + "on_duty": "On Duty", + "off_duty": "Off Duty" }, "error": { "cant_promote": "No puedes cambiar el grado de una persona cuyo grado es mayor o igual al tuyo...", diff --git a/locales/pt.json b/locales/pt.json index f2aca72..a9e7444 100644 --- a/locales/pt.json +++ b/locales/pt.json @@ -16,7 +16,12 @@ "gang_menu": "Menu de Gangue", "boss_menu": "Menu de Chefia", "gang_management": "[E] - Abrir Gestão de Gangues", - "boss_management": "[E] - Abrir Gestão de Chefia" + "boss_management": "[E] - Abrir Gestão de Chefia", + "hours_in_days": "Hours worked in 30 days", + "last_checkin": "Last check in", + "employee_status": "Employee status", + "on_duty": "On Duty", + "off_duty": "Off Duty" }, "error": { "cant_promote": "Não podes mudar o grau de uma pessoa cujo grau seja maior ou igual ao teu...", diff --git a/locales/tr.json b/locales/tr.json index ed2e0af..4b504b9 100644 --- a/locales/tr.json +++ b/locales/tr.json @@ -16,7 +16,12 @@ "gang_menu": "Çete Menüsü", "boss_menu": "Patron Menüsü", "gang_management": "[E] - Çete Yönetimini Aç", - "boss_management": "[E] - Patron Yönetimini Aç" + "boss_management": "[E] - Patron Yönetimini Aç", + "hours_in_days": "Hours worked in 30 days", + "last_checkin": "Last check in", + "employee_status": "Employee status", + "on_duty": "On Duty", + "off_duty": "Off Duty" }, "error": { "cant_promote": "Kendi rütbeniz eşit veya büyük olan birinin rütbesini değiştiremezsiniz...", diff --git a/qbx_management.sql b/qbx_management.sql new file mode 100644 index 0000000..634c03b --- /dev/null +++ b/qbx_management.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS `player_jobs_activity` ( + `id` int UNSIGNED NOT NULL AUTO_INCREMENT, + `citizenid` VARCHAR(50) COLLATE utf8mb4_unicode_ci, + `job` varchar(255) NOT NULL, + `last_checkin` int NOT NULL, + `last_checkout` int NULL DEFAULT NULL, + FOREIGN KEY (`citizenid`) REFERENCES `players` (`citizenid`) ON DELETE CASCADE, + PRIMARY KEY (`id`) USING BTREE, + INDEX `id` (`id` DESC) USING BTREE, + INDEX `last_checkout` (`last_checkout` ASC) USING BTREE, + INDEX `citizenid_job` (`citizenid`, `job`) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci; \ No newline at end of file diff --git a/server/main.lua b/server/main.lua index 70260de..381c27e 100644 --- a/server/main.lua +++ b/server/main.lua @@ -6,16 +6,17 @@ local config = require 'config.server' local logger = require '@qbx_core.modules.logger' local JOBS = exports.qbx_core:GetJobs() local GANGS = exports.qbx_core:GetGangs() +local playersClockedIn = {} local menus = {} for groupName, menuInfo in pairs(config.menus) do ---@diagnostic disable-next-line: inject-field - menuInfo.groupName = groupName - menus[#menus + 1] = menuInfo + menuInfo.groupName = groupName + menus[#menus + 1] = menuInfo end local function getMenuEntries(groupName, groupType) - local menuEntries = {} + local menuEntries = {} local groupEntries = exports.qbx_core:GetGroupMembers(groupName, groupType) for i = 1, #groupEntries do @@ -23,14 +24,20 @@ local function getMenuEntries(groupName, groupType) local grade = groupEntries[i].grade local player = exports.qbx_core:GetPlayerByCitizenId(citizenid) or exports.qbx_core:GetOfflinePlayer(citizenid) local namePrefix = player.Offline and '❌ ' or '🟢 ' + local playerActivityData = groupType == 'job' and GetPlayerActivityData(citizenid, groupName) or nil + local playerClockData = playersClockedIn[player.PlayerData.source] + local playerLastCheckIn = playerClockData and os.date(config.formatDateTime, playerClockData.time) or playerActivityData?.last_checkin menuEntries[#menuEntries + 1] = { cid = citizenid, - grade = grade, - name = namePrefix..player.PlayerData.charinfo.firstname..' '..player.PlayerData.charinfo.lastname + grade = grade, + name = namePrefix..player.PlayerData.charinfo.firstname..' '..player.PlayerData.charinfo.lastname, + onduty = player.PlayerData.job.onduty and not player.Offline, + hours = playerActivityData?.hours, + last_checkin = playerLastCheckIn } end - return menuEntries + return menuEntries end -- Get a list of employees for a given group. @@ -38,15 +45,15 @@ end ---@param groupType GroupType ---@return table? lib.callback.register('qbx_management:server:getEmployees', function(source, groupName, groupType) - local player = exports.qbx_core:GetPlayer(source) - if not player.PlayerData[groupType].isboss then return end + local player = exports.qbx_core:GetPlayer(source) + if not player.PlayerData[groupType].isboss then return end - local menuEntries = getMenuEntries(groupName, groupType) + local menuEntries = getMenuEntries(groupName, groupType) table.sort(menuEntries, function(a, b) - return a.grade > b.grade - end) + return a.grade > b.grade + end) - return menuEntries + return menuEntries end) -- Callback for updating the grade information of online players @@ -56,33 +63,33 @@ end) ---@param newGrade integer New grade number of target employee ---@param groupType GroupType lib.callback.register('qbx_management:server:updateGrade', function(source, citizenId, oldGrade, newGrade, groupType) - local player = exports.qbx_core:GetPlayer(source) - local employee = exports.qbx_core:GetPlayerByCitizenId(citizenId) - local jobName = player.PlayerData[groupType].name - local gradeLevel = player.PlayerData[groupType].grade.level + local player = exports.qbx_core:GetPlayer(source) + local employee = exports.qbx_core:GetPlayerByCitizenId(citizenId) + local jobName = player.PlayerData[groupType].name + local gradeLevel = player.PlayerData[groupType].grade.level - if not player.PlayerData[groupType].isboss then return end + if not player.PlayerData[groupType].isboss then return end - if player.PlayerData.citizenid == citizenId then - exports.qbx_core:Notify(source, locale('error.cant_promote_self'), 'error') - return - end + if player.PlayerData.citizenid == citizenId then + exports.qbx_core:Notify(source, locale('error.cant_promote_self'), 'error') + return + end - if oldGrade >= gradeLevel or newGrade >= gradeLevel then - exports.qbx_core:Notify(source, locale('error.cant_promote'), 'error') - return - end + if oldGrade >= gradeLevel or newGrade >= gradeLevel then + exports.qbx_core:Notify(source, locale('error.cant_promote'), 'error') + return + end if groupType == 'job' then local success, errorResult = exports.qbx_core:AddPlayerToJob(citizenId, jobName, newGrade) - assert(success, errorResult.message) + assert(success, errorResult.message) else local success, errorResult = exports.qbx_core:AddPlayerToGang(citizenId, jobName, newGrade) - assert(success, errorResult.message) + assert(success, errorResult.message) end if employee then - local gradeName = groupType == 'gang' and GANGS[jobName].grades[newGrade].name or JOBS[jobName].grades[newGrade].name + local gradeName = groupType == 'gang' and GANGS[jobName].grades[newGrade].name or JOBS[jobName].grades[newGrade].name exports.qbx_core:Notify(employee.PlayerData.source, locale('success.promoted_to')..gradeName..'.', 'success') end exports.qbx_core:Notify(source, locale('success.promoted'), 'success') @@ -92,8 +99,8 @@ end) ---@param employee integer Server ID of target employee to be hired ---@param groupType GroupType lib.callback.register('qbx_management:server:hireEmployee', function(source, employee, groupType) - local player = exports.qbx_core:GetPlayer(source) - local target = exports.qbx_core:GetPlayer(employee) + local player = exports.qbx_core:GetPlayer(source) + local target = exports.qbx_core:GetPlayer(employee) if not player.PlayerData[groupType].isboss then return end @@ -102,19 +109,19 @@ lib.callback.register('qbx_management:server:hireEmployee', function(source, emp return end - local groupName = player.PlayerData[groupType].name - local logArea = groupType == 'gang' and 'Gang' or 'Boss' + local groupName = player.PlayerData[groupType].name + local logArea = groupType == 'gang' and 'Gang' or 'Boss' if groupType == 'job' then local success, errorResult = exports.qbx_core:AddPlayerToJob(target.PlayerData.citizenid, groupName, 0) - assert(success, errorResult.message) + assert(success, errorResult.message) success, errorResult = exports.qbx_core:SetPlayerPrimaryJob(target.PlayerData.citizenid, groupName) - assert(success, errorResult.message) + assert(success, errorResult.message) else local success, errorResult = exports.qbx_core:AddPlayerToGang(target.PlayerData.citizenid, groupName, 0) - assert(success, errorResult.message) + assert(success, errorResult.message) success, errorResult = exports.qbx_core:SetPlayerPrimaryGang(target.PlayerData.citizenid, groupName) - assert(success, errorResult.message) + assert(success, errorResult.message) end local playerFullName = player.PlayerData.charinfo.firstname..' '..player.PlayerData.charinfo.lastname @@ -129,24 +136,24 @@ end) ---@param closePlayers table Table of player data for possible hiring ---@return table lib.callback.register('qbx_management:server:getPlayers', function(_, closePlayers) - local players = {} - for _, v in pairs(closePlayers) do - local player = exports.qbx_core:GetPlayer(v.id) - players[#players + 1] = { - id = v.id, - name = player.PlayerData.charinfo.firstname..' '..player.PlayerData.charinfo.lastname, - citizenid = player.PlayerData.citizenid, - job = player.PlayerData.job, - gang = player.PlayerData.gang, - source = player.PlayerData.source - } - end - - table.sort(players, function(a, b) - return a.name < b.name - end) - - return players + local players = {} + for _, v in pairs(closePlayers) do + local player = exports.qbx_core:GetPlayer(v.id) + players[#players + 1] = { + id = v.id, + name = player.PlayerData.charinfo.firstname..' '..player.PlayerData.charinfo.lastname, + citizenid = player.PlayerData.citizenid, + job = player.PlayerData.job, + gang = player.PlayerData.gang, + source = player.PlayerData.source + } + end + + table.sort(players, function(a, b) + return a.name < b.name + end) + + return players end) @@ -159,33 +166,33 @@ end) local function fireEmployee(employeeCitizenId, boss, groupName, groupType) local employee = exports.qbx_core:GetPlayerByCitizenId(employeeCitizenId) or exports.qbx_core:GetOfflinePlayer(employeeCitizenId) if employee.PlayerData.citizenid == boss.PlayerData.citizenid then - local message = groupType == 'gang' and locale('error.kick_yourself') or locale('error.fire_yourself') - exports.qbx_core:Notify(boss.PlayerData.source, message, 'error') - return false - end + local message = groupType == 'gang' and locale('error.kick_yourself') or locale('error.fire_yourself') + exports.qbx_core:Notify(boss.PlayerData.source, message, 'error') + return false + end if not employee then - exports.qbx_core:Notify(boss.PlayerData.source, locale('error.person_doesnt_exist'), 'error') - return false - end + exports.qbx_core:Notify(boss.PlayerData.source, locale('error.person_doesnt_exist'), 'error') + return false + end local employeeGrade = groupType == 'job' and employee.PlayerData.jobs?[groupName] or employee.PlayerData.gangs?[groupName] local bossGrade = groupType == 'job' and boss.PlayerData.jobs?[groupName] or boss.PlayerData.gangs?[groupName] if employeeGrade >= bossGrade then - exports.qbx_core:Notify(boss.PlayerData.source, locale('error.fire_boss'), 'error') - return false - end + exports.qbx_core:Notify(boss.PlayerData.source, locale('error.fire_boss'), 'error') + return false + end - if groupType == 'job' then + if groupType == 'job' then local success, errorResult = exports.qbx_core:RemovePlayerFromJob(employee.PlayerData.citizenid, groupName) - assert(success, errorResult.message) - else + assert(success, errorResult.message) + else local success, errorResult = exports.qbx_core:RemovePlayerFromGang(employee.PlayerData.citizenid, groupName) - assert(success, errorResult.message) - end + assert(success, errorResult.message) + end if not employee.Offline then local message = groupType == 'gang' and locale('error.you_gang_fired', GANGS[groupName].label) or locale('error.you_job_fired', JOBS[groupName].label) - exports.qbx_core:Notify(employee.PlayerData.source, message, 'error') + exports.qbx_core:Notify(employee.PlayerData.source, message, 'error') end return true @@ -195,35 +202,95 @@ end ---@param employee string citizenid of employee to be fired ---@param groupType GroupType lib.callback.register('qbx_management:server:fireEmployee', function(source, employee, groupType) - local player = exports.qbx_core:GetPlayer(source) - local firedEmployee = exports.qbx_core:GetPlayerByCitizenId(employee) or exports.qbx_core:GetOfflinePlayer(employee) - local playerFullName = player.PlayerData.charinfo.firstname..' '..player.PlayerData.charinfo.lastname - local organizationLabel = player.PlayerData[groupType].label + local player = exports.qbx_core:GetPlayer(source) + local firedEmployee = exports.qbx_core:GetPlayerByCitizenId(employee) or exports.qbx_core:GetOfflinePlayer(employee) + local playerFullName = player.PlayerData.charinfo.firstname..' '..player.PlayerData.charinfo.lastname + local organizationLabel = player.PlayerData[groupType].label - if not player.PlayerData[groupType].isboss then return end + if not player.PlayerData[groupType].isboss then return end if not firedEmployee then lib.print.error("not able to find player with citizenid", employee) return end local success = fireEmployee(employee, player, player.PlayerData[groupType].name, groupType) local employeeFullName = firedEmployee.PlayerData.charinfo.firstname..' '..firedEmployee.PlayerData.charinfo.lastname - if success then - local logArea = groupType == 'gang' and 'Gang' or 'Boss' - local logType = groupType == 'gang' and locale('error.gang_fired') or locale('error.job_fired') - exports.qbx_core:Notify(source, logType, 'success') - logger.log({source = 'qbx_management', event = 'fireEmployee', message = string.format('%s | %s fired %s from %s', logArea, playerFullName, employeeFullName, organizationLabel), webhook = config.discordWebhook}) - else - exports.qbx_core:Notify(source, locale('error.unable_fire'), 'error') - end + if success then + local logArea = groupType == 'gang' and 'Gang' or 'Boss' + local logType = groupType == 'gang' and locale('error.gang_fired') or locale('error.job_fired') + exports.qbx_core:Notify(source, logType, 'success') + logger.log({source = 'qbx_management', event = 'fireEmployee', message = string.format('%s | %s fired %s from %s', logArea, playerFullName, employeeFullName, organizationLabel), webhook = config.discordWebhook}) + else + exports.qbx_core:Notify(source, locale('error.unable_fire'), 'error') + end end) lib.callback.register('qbx_management:server:getBossMenus', function() - return menus + return menus end) ---Creates a boss zone for the specified group ---@param menuInfo MenuInfo local function registerBossMenu(menuInfo) menus[#menus + 1] = menuInfo - TriggerClientEvent('qbx_management:client:bossMenuRegistered', -1, menuInfo) + TriggerClientEvent('qbx_management:client:bossMenuRegistered', -1, menuInfo) end exports('RegisterBossMenu', registerBossMenu) + +---@param source number +---@param citizenid string +---@param job string +local function doPlayerCheckIn(source, citizenid, job) + playersClockedIn[source] = { citizenid = citizenid, job = job, time = os.time() } +end + +---@param source number +local function onPlayerUnload(source) + if playersClockedIn[source] then + OnPlayerCheckOut(playersClockedIn[source]) + playersClockedIn[source] = nil + end +end + +---@param source number +RegisterNetEvent('QBCore:Server:OnPlayerLoaded', function() + local player = exports.qbx_core:GetPlayer(source) + if player == nil then return end + if player.PlayerData.job.onduty then + doPlayerCheckIn(player.PlayerData.source, player.PlayerData.citizenid, player.PlayerData.job.name) + end +end) + +---@param source number +---@param job table? +AddEventHandler('QBCore:Server:OnJobUpdate', function(source, job) + if playersClockedIn[source] then + onPlayerUnload(source) + return + end + local player = exports.qbx_core:GetPlayer(source) + if player == nil then return end + if player.PlayerData.job.onduty then + doPlayerCheckIn(player.PlayerData.source, player.PlayerData.citizenid, job.name) + end +end) + +---@param source number +---@param duty boolean +AddEventHandler('QBCore:Server:SetDuty', function(source, duty) + local player = exports.qbx_core:GetPlayer(source) + if player == nil then return end + if duty then + doPlayerCheckIn(player.PlayerData.source, player.PlayerData.citizenid, player.PlayerData.job.name) + else + onPlayerUnload(player.PlayerData.source) + end +end) + +---@param source number +AddEventHandler('QBCore:Server:OnPlayerUnload', function() + onPlayerUnload(source) +end) + +---@param source number +AddEventHandler('playerDropped', function() + onPlayerUnload(source) +end) \ No newline at end of file diff --git a/server/storage.lua b/server/storage.lua new file mode 100644 index 0000000..1c9360a --- /dev/null +++ b/server/storage.lua @@ -0,0 +1,26 @@ +local config = require 'config.server' + +MySQL.query('DELETE FROM `player_jobs_activity` WHERE `last_checkout` < UNIX_TIMESTAMP(DATE_SUB(NOW(), INTERVAL 31 DAY)) OR `last_checkout` IS NULL') + +function OnPlayerCheckOut(clockInPayload) + local checkOutTime = os.time() + local checkInTime = clockInPayload.time + if (checkOutTime - checkInTime) / 60 < config.minOnDutyLogTimeMinutes then + return + end + + MySQL.insert("INSERT INTO `player_jobs_activity` (`citizenid`, `job`, `last_checkin`, `last_checkout`) VALUES (?, ?, ?, ?)", { + clockInPayload.citizenid, + clockInPayload.job, + checkInTime, + checkOutTime, + }) +end + +---@param citizenid string +---@param job string +---@return table? +function GetPlayerActivityData(citizenid, job) + local result = MySQL.single.await('SELECT `last_checkin`, ROUND(COALESCE(SUM(last_checkout-last_checkin) / 3600, 0), 2) AS `hours` FROM `player_jobs_activity` WHERE `citizenid` = ? AND `job` = ? GROUP BY `citizenid`', { citizenid, job }) + return { hours = result.hours, last_checkin = result.last_checkin and os.date(config.formatDateTime, result.last_checkin) or 'N/A' } +end \ No newline at end of file