diff --git a/code/modules/admin/admin_verbs.dm b/code/modules/admin/admin_verbs.dm index 92d51100a5f..d686c110e6c 100644 --- a/code/modules/admin/admin_verbs.dm +++ b/code/modules/admin/admin_verbs.dm @@ -92,6 +92,7 @@ GLOBAL_PROTECT(admin_verbs_admin) /client/proc/toggle_view_range, /*changes how far we can see*/ /client/proc/cmd_admin_law_panel, /client/proc/log_viewer_new, + /client/proc/player_ticket_history, ) GLOBAL_LIST_INIT(admin_verbs_ban, list(/client/proc/unban_panel, /client/proc/ban_panel, /client/proc/stickybanpanel, /client/proc/library_control)) GLOBAL_PROTECT(admin_verbs_ban) diff --git a/code/modules/admin/topic.dm b/code/modules/admin/topic.dm index 6491135b5fd..d06be1d79c0 100644 --- a/code/modules/admin/topic.dm +++ b/code/modules/admin/topic.dm @@ -1122,6 +1122,15 @@ return M.mind_initialize() + else if(href_list["player_ticket_history"]) + if(!check_rights(R_ADMIN)) + return + var/target_ckey = href_list["player_ticket_history"] + GLOB.player_ticket_history.cache_history_for_ckey(target_ckey) + GLOB.player_ticket_history.user_selections[usr.ckey] = target_ckey + GLOB.player_ticket_history.ui_interact(usr) + return + else if(href_list["create_object"]) if(!check_rights(R_SPAWN)) return diff --git a/code/modules/admin/verbs/adminhelp.dm b/code/modules/admin/verbs/adminhelp.dm index 5e0a5d76c81..e0f05d197f2 100644 --- a/code/modules/admin/verbs/adminhelp.dm +++ b/code/modules/admin/verbs/adminhelp.dm @@ -573,6 +573,10 @@ GLOBAL_DATUM_INIT(ahelp_tickets, /datum/admin_help_tickets, new) for(var/I in ticket_interactions) dat += "[I]" + // Helper for opening directly to player ticket history + dat += "Player Ticket History:" + dat += "[FOURSPACES]Open" + // Append any tickets also opened by this user if relevant var/list/related_tickets = GLOB.ahelp_tickets.TicketsByCKey(initiator_ckey) if (related_tickets.len > 1) diff --git a/code/modules/admin/verbs/player_ticket_history.dm b/code/modules/admin/verbs/player_ticket_history.dm new file mode 100644 index 00000000000..ac87707e98d --- /dev/null +++ b/code/modules/admin/verbs/player_ticket_history.dm @@ -0,0 +1,198 @@ +/// Holds information about a ticket +/datum/ticket_history + var/player_ckey + /// Number of the ticket *for that round* + var/ticket_number + var/round_id + var/list/ticket_log + +/datum/ticket_log_entry + var/timestamp + var/origin_ckey + var/target_ckey + var/action + var/message + +GENERAL_PROTECT_DATUM(/datum/ticket_history) +GENERAL_PROTECT_DATUM(/datum/ticket_log_entry) + +GLOBAL_DATUM_INIT(player_ticket_history, /datum/ticket_history_holder, new) +GLOBAL_PROTECT(player_ticket_history) + +/client/proc/player_ticket_history() + set name = "Player Ticket History" + set desc = "Allows you to view the ticket history of a player." + set category = "Admin" + if(!check_rights(R_ADMIN)) + return + GLOB.player_ticket_history.ui_interact(mob) + +/datum/ticket_history_holder + /// Assosciative list of ticket histories. ckey -> list/datum/ticket_history + var/list/ticket_histories = list() + /// Assosciative list of user_ckey -> target_ckey + var/list/user_selections = list() + +/datum/ticket_history_holder/proc/cache_history_for_ckey(ckey, entries = 5) + ckey = lowertext(ckey) + + if(!isnum(entries) || entries <= 0) + return + + var/list/datum/ticket_history/history_cache = list() + ticket_histories[ckey] = history_cache + + var/datum/db_query/ticket_lookup = SSdbcore.NewQuery("\ + WITH DISTINCT_TICKETS AS ( \ + SELECT id, round_id, ticket, sender, recipient FROM [format_table_name("ticket")] \ + WHERE id IN ( \ + SELECT MAX(id) FROM ticket GROUP BY round_id, ticket \ + ) \ + AND round_id != :current_round \ + ) \ + SELECT round_id, ticket FROM DISTINCT_TICKETS \ + WHERE sender = :ckey OR recipient = :ckey \ + ORDER BY id DESC \ + LIMIT :max_entries", + list( + "ckey" = ckey, + "current_round" = GLOB.round_id, + "max_entries" = entries, + ) + ) + if(!ticket_lookup.Execute()) + qdel(ticket_lookup) + to_chat(usr, "Failed to query ticket history for [ckey]!") + return + + var/list/lookup_targets = list() + // round-ticket + while(ticket_lookup.NextRow()) + lookup_targets += "[ticket_lookup.item[1]]-[ticket_lookup.item[2]]" + qdel(ticket_lookup) + + for(var/lookup_string in lookup_targets) + ASYNC + var/datum/ticket_history/ticket_history = new + history_cache += ticket_history + + var/round = splittext(lookup_string, "-")[1] + var/ticket = splittext(lookup_string, "-")[2] + + ticket_history.round_id = text2num(round) + ticket_history.ticket_number = text2num(ticket) + + var/datum/db_query/ticket_lookup_instance = SSdbcore.NewQuery("\ + SELECT action, message, timestamp, recipient, sender \ + FROM [format_table_name("ticket")] \ + WHERE round_id = :round AND ticket = :ticket \ + ORDER BY id DESC \ + ", list( + "round" = round, + "ticket" = ticket + )) + if(!ticket_lookup_instance.warn_execute()) + qdel(ticket_lookup_instance) + lookup_targets -= lookup_string + continue + + var/list/ticket_log = list() + while(ticket_lookup_instance.NextRow()) + var/datum/ticket_log_entry/log_entry = new + ticket_log += log_entry + log_entry.action = ticket_lookup_instance.item[1] + log_entry.message = ticket_lookup_instance.item[2] + log_entry.timestamp = ticket_lookup_instance.item[3] + log_entry.target_ckey = ticket_lookup_instance.item[4] + log_entry.origin_ckey = ticket_lookup_instance.item[5] + qdel(ticket_lookup_instance) + ticket_history.ticket_log = ticket_log + lookup_targets -= lookup_string + + // wait for all the queries to finish + UNTIL(lookup_targets.len == 0) + + if(!length(history_cache)) + to_chat(usr, span_adminnotice("No ticket history found for [ckey]!")) + ticket_histories -= ckey + return + + to_chat(usr, span_adminnotice("Finished caching ticket history for [ckey]!")) + +/datum/ticket_history_holder/ui_state(mob/user) + return GLOB.admin_state + +/datum/ticket_history_holder/ui_static_data(mob/user) + if(!check_rights_for(CLIENT_FROM_VAR(user), R_ADMIN)) + return list() + + if(!SSdbcore.IsConnected()) + return list( + "db_connected" = 0, + ) + + var/list/data = list( + "db_connected" = TRUE, + ) + var/list/cached_ckeys = list() + for(var/ckey in ticket_histories) + cached_ckeys += ckey + data["cached_ckeys"] = cached_ckeys + + if(user.ckey in user_selections) + var/list/ticket_cache = list() + for(var/datum/ticket_history/ticket_history as anything in ticket_histories[user_selections[user.ckey]]) + var/list/ticket_data = list( + "ticket_number" = ticket_history.ticket_number, + "round_id" = ticket_history.round_id, + ) + var/list/ticket_log = list() + for(var/datum/ticket_log_entry/entry as anything in ticket_history.ticket_log) + ticket_log += list(list( + "timestamp" = entry.timestamp, + "origin_ckey" = entry.origin_ckey, + "target_ckey" = entry.target_ckey, + "action" = entry.action, + "message" = entry.message, + )) + + ticket_data["ticket_log"] = ticket_log + ticket_cache += list(ticket_data) + data["ticket_cache"] = ticket_cache + data["target_ckey"] = user_selections[user.ckey] + + return data + +/datum/ticket_history_holder/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + . = ..() + if(.) + return + + switch(action) + if("select-user") + var/target = params["target"] + if(!(target in ticket_histories)) + return TRUE + user_selections[ui.user.ckey] = target + SStgui.update_static_data(ui.user, ui) + return TRUE + + if("cache-user") + var/target = params["target"] + var/amount = ("amount" in params) ? params["amount"] : 5 + cache_history_for_ckey(target, amount) + SStgui.update_static_data(ui.user, ui) + return TRUE + + else + stack_trace("[type]/ui_act: unknown action type [action]") + +/datum/ticket_history_holder/ui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if(!isnull(ui)) + ui.send_full_update() + return + + ui = new(user, src, "PlayerTicketHistory") + ui.set_autoupdate(FALSE) + ui.open() diff --git a/tgstation.dme b/tgstation.dme index 9f25567ac5c..0762d28d2f0 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -2410,6 +2410,7 @@ #include "code\modules\admin\verbs\maprotation.dm" #include "code\modules\admin\verbs\panicbunker.dm" #include "code\modules\admin\verbs\plane_debugger.dm" +#include "code\modules\admin\verbs\player_ticket_history.dm" #include "code\modules\admin\verbs\playsound.dm" #include "code\modules\admin\verbs\possess.dm" #include "code\modules\admin\verbs\pray.dm" diff --git a/tgui/packages/tgui/interfaces/PlayerTicketHistory.tsx b/tgui/packages/tgui/interfaces/PlayerTicketHistory.tsx new file mode 100644 index 00000000000..d2bc5f4e079 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PlayerTicketHistory.tsx @@ -0,0 +1,237 @@ +import { useBackend, useLocalState } from '../backend'; +import { NoticeBox, Section, Tabs, Input, Button, NumberInput, Stack, Collapsible } from '../components'; +import { Window } from '../layouts'; + +type PthData = { + db_connected: boolean; + cached_ckeys: string[]; + ticket_cache: TicketData[]; + target_ckey?: string; +}; + +type TicketData = { + ticket_number: number; + round_id: number; + ticket_log: TicketLogData[]; +}; + +type TicketLogData = { + timestamp: string; + origin_ckey: string; + target_ckey: string; + action: string; + message: string; +}; + +enum Pages { + Cache = 1, + TicketHistory = 2, +} + +export const PlayerTicketHistory = (props: any, context: any) => { + const { act, data } = useBackend(context); + + const [page, setPage] = useLocalState( + context, + 'page', + data.target_ckey ? Pages.TicketHistory : Pages.Cache + ); + + const [cacheInput, setCacheInput] = useLocalState(context, 'cacheInput', ''); + const [cacheCount, setCacheCount] = useLocalState(context, 'cacheCount', 5); + + if (!data.db_connected) { + return ( + + + The database is not connected. + + + ); + } + + return ( + + + + setPage(Pages.Cache)}> + Cache + + setPage(Pages.TicketHistory)}> + Ticket History + + + {page === Pages.TicketHistory && } + {page === Pages.Cache && ( + + )} + + + ); +}; + +const TicketHistory = (props: any, context: any) => { + const { act, data } = useBackend(context); + + if (data.ticket_cache === undefined) { + return ( + + No player selected. + + ); + } + + const [activeTicket, setActiveTicket] = useLocalState( + context, + 'ticket', + undefined + ); + + // sory by round then ticket number, descending + data.ticket_cache.sort((b, a) => { + if (a.round_id === b.round_id) { + return a.ticket_number - b.ticket_number; + } + return a.round_id - b.round_id; + }); + + return ( + + Tickets in order of most recent to oldest: + + + + {data.ticket_cache.map((ticket, index) => ( + + { + setActiveTicket(ticket); + }}> + {`${ticket.round_id} #${ticket.ticket_number}`} + + + ))} + + + + {activeTicket === undefined ? ( + No ticket selected. + ) : ( + + )} + + ); +}; + +type CacheProps = { + cacheInput: string; + setCacheInput: (value: string) => void; + cacheCount: number; + setCacheCount: (value: number) => void; +}; + +const Cache = (props: CacheProps, context: any) => { + const { act, data } = useBackend(context); + + return ( + + + Query and cache: + + props.setCacheInput(value.toLowerCase()) + } + /> + props.setCacheCount(value)} + /> + { + act('cache-user', { + target: props.cacheInput, + amount: props.cacheCount, + }); + }} + /> + + + {data.cached_ckeys.map((ckey) => ( + { + act('select-user', { target: ckey }); + }}> + {ckey} + + ))} + + + ); +}; + +type TicketViewProps = { + ticket: TicketData; +}; + +const TicketView = (props: TicketViewProps, context: any) => { + const { act, data } = useBackend(context); + const [forceExpand, setForceExpand] = useLocalState( + context, + 'forceExpand', + false + ); + + // sort by timestamp + props.ticket.ticket_log.sort((a, b) => { + return a.timestamp.localeCompare(b.timestamp); + }); + + return ( + setForceExpand(!forceExpand)} + /> + }> + {props.ticket.ticket_log.map((log, index) => ( + ${log.target_ckey}` : '' + }`}> + {log.message} + + ))} + + ); +};