diff --git a/locales/en.json b/locales/en.json index a0637a9..26c54de 100644 --- a/locales/en.json +++ b/locales/en.json @@ -423,6 +423,36 @@ "edit-modal-username-placeholder": "Username of the user", "user-not-found": "User not found" }, + "guess-the-number": { + "command-description": "Manage your guess-the-number-games", + "status-command-description": "Shows the current status of a guess-the-number-game in this channel", + "create-command-description": "Create a new guess-the-number-game in this channel", + "create-min-description": "Minimal value users can guess", + "create-max-description": "Maximal value users can guess", + "create-number-description": "Number users should guess to win", + "end-command-description": "Ends the current game", + "session-already-running": "There is a session already running in this channel. Please end it with /guess-the-number end", + "session-not-running": "There is currently no session running.", + "gamechannel-modus": "You can't use this command in a gamechannel. To end a game, disable the gamechannel modus and try using the end command.", + "session-ended-successfully": "Ended session successfully. Locked channel successfully.", + "current-session": "Current session", + "number": "Number", + "min-val": "Min-Value", + "max-val": "Max-Value", + "owner": "Owner", + "guess-count": "Count of guesses", + "min-max-discrepancy": "`min` can't be bigger or equal to `max`", + "max-discrepancy": "`number` can't be bigger than `max`.", + "min-discrepancy": "`number` can't be smaller than `min`.", + "emoji-guide-button": "What does the reaction under my guess mean?", + "guide-wrong-guess": "Your guess was wrong (but your entry was valid)", + "guide-win": "You guessed correctly - you win :tada:", + "guide-admin-guess": "Your guess was invalid, because you are an admin - admins can't participate because they can see the correct number", + "guide-invalid-guess": "Your guess was invalid (e.g. below the minimal / over the maximal number, not a number, â€Ļ)", + "created-successfully": "Created game successfully. Users can now start guessing in this channel. The winning number is **%n**. You can always check the status by running `/guess-the-number-status`. Note that you as an admin can not guess.", + "game-ended": "Game ended", + "game-started": "Game started" + }, "massrole": { "command-description": "Manage roles for all members", "add-subcommand-description": "Add a role to all members", @@ -993,5 +1023,394 @@ "label-jump": "Jump to Message", "no-message-link": "This ping was blocked by AutoMod", "list-entry-text": "%index. **Pinged %target** at %time\n%link" + }, + "staff-management-system": { + "time-zero": "0 seconds", + "time-hours": "hours", + "time-hour": "hour", + "time-mins": "minutes", + "time-min": "minute", + "time-secs": "seconds", + "time-sec": "second", + "stat-brk": "🟡 On Break", + "stat-on": "đŸŸĸ On-Duty", + "stat-off": "🔴 Off-Duty", + "duty-panel-title": "Duty Panel - %type", + "duty-stats": "📊 Statistics", + "duty-stat-desc": "**Total Shift Duration:** %duration\n**Total Shifts:** %count\n**Average Shift Duration:** %average", + "btn-duty-on": "On-Duty", + "btn-duty-res": "Resume Duty", + "btn-duty-brk": "Toggle Break", + "btn-duty-off": "Off-Duty", + "duty-breakdown": "Shift Breakdown", + "duty-quota-str": "\n\n**Quota (%timeframe):** %duration / %hours hours\n*%result*", + "quota-met": "✅ Quota Met", + "quota-fail": "❌ Quota Not Met", + "duty-time-title": "Shift Time - %type", + "duty-time-desc": "**Total Shifts:** %count\n**Total Duration:** %duration", + "btn-hist": "View History", + "err-no-lb": "â„šī¸ No shift data found for **%type**.", + "duty-lb-title": "Leaderboard - %type", + "duty-lb-desc": "**%lookback Top Shifts**\n\n%lines", + "page-count": "Page %page/%total", + "info-no-sh-hi": "â„šī¸ No completed shifts found.", + "duty-hi-title": "Shift History - %type", + "duty-adm-title": "Admin Duty Panel - %user", + "btn-f-off": "Force Off-Duty", + "btn-v-act": "Void Active Shift", + "btn-add-t": "Add Time", + "btn-v-all": "Void All Shifts", + "err-not-yours": "❌ This panel is not yours.", + "err-alr-on": "❌ You are already on a shift.", + "err-not-on": "❌ You are not on a shift.", + "err-hist-oth": "❌ You can only view your own history.", + "mod-v-all-title": "Confirm: Void All Shifts", + "mod-v-all-lbl": "Type CONFIRM to delete all shift data", + "err-conf-fail": "❌ Data deletion confirmation failed. You must type the phrase exactly.", + "succ-v-all": "All shift data for <@%user> has been deleted successfully.", + "mod-add-t": "Add Duty Time", + "mod-add-min": "Minutes to add", + "mod-add-type": "Shift Type", + "err-inv-min": "❌ Invalid number of minutes.", + "err-inv-type": "❌ Invalid shift type. Available: %types", + "err-sh-dis": "❌ Shift tracking is disabled.", + "info-no-act-sh": "â„šī¸ There are no active shifts right now.", + "duty-act-title": "Active Shifts", + "duty-act-desc": "**Total Shifts:** %count", + "err-no-perm": "❌ You do not have permission to do this.", + "err-no-mem": "❌ Could not find that member.", + "ph-sel-type": "Select a Shift Type", + "msg-sel-type": "👇 Please choose your shift type:", + "err-prof-dis": "❌ Staff Profiles are disabled.", + "err-prof-cfg": "❌ Configuration is missing. Please make sure the message is not empty.", + "err-prof-no-own": "❌ You do not have a staff profile.", + "err-prof-no-tgt": "❌ That user does not have a profile.", + "rev-dis-text": "*Reviews disabled*", + "rev-no-rate": "No ratings yet", + "stat-offl": "âšĢ Offline", + "stat-onl": "đŸŸĸ Online", + "stat-idl": "🟡 Away", + "stat-dnd": "🔴 Do Not Disturb", + "stat-prof-ond": "âąī¸ On duty", + "stat-prof-loa": "🌙 On LoA", + "stat-prof-ra": "â›ąī¸ On RA", + "prof-no-intro": "*No introduction set.*", + "err-prof-empty": "❌ Profile embed is empty.", + "err-prof-perm": "❌ You must be a staff member to have a profile.", + "prof-edit-title": "Edit Profile", + "prof-edit-nick": "Custom Nickname", + "prof-edit-intro": "Introduction", + "succ-prof-wipe": "✅ Profile wiped for %u.", + "succ-prof-upd": "✅ Profile updated!", + "general-chan": "Channel", + "general-ends": "Ends", + "ac-tot-res": "Total Responded", + "err-ac-noact": "❌ There is no active activity check.", + "succ-ac-end": "✅ Activity check ended manually.", + "err-gen-no-user": "❌ Could not find that user.", + "del-conf-phrase": "I understand that this will delete the specified data for this user and it cannot be undone.", + "mod-del-title": "Confirm Data Deletion", + "mod-del-lbl": "Type confirmation phrase:", + "del-all-title": "Confirm total data deletion", + "del-all-desc": "You are about to delete ALL data for this user. Reminder that this ***cannot be undone***. This is the last chance to back out. If you are sure, click the button below.\nThis action will automatically cancel in 30 seconds.", + "btn-conf-del": "Confirm deletion", + "btn-cancel": "Cancel", + "succ-del-canc": "✅ Data deletion cancelled.", + "succ-del-all": "✅ ALL data has been permanently wiped.", + "err-del-time": "âŗ Data deletion timed out.", + "succ-del-tgt": "✅ Target data has been permanently wiped.", + "err-gen-no-perm": "❌ You do not have permission.", + "err-no-req": "❌ Request not found.", + "err-req-hndl": "❌ Request is already %status.", + "mod-deny-req": "Deny Request", + "general-rsn": "Reason", + "label-appr-by": "This was approved by", + "req-appr-by": "✅ Approved by %user", + "req-deny-by": "❌ Denied by %user", + "general-stat": "Status", + "err-ac-alr-end": "❌ This activity check has already ended.", + "err-ac-notreq": "❌ You are not required to respond to this.", + "info-ac-alr-conf": "â„šī¸ You already confirmed your activity!", + "succ-ac-log": "✅ Activity logged successfully!", + "err-internal": "❌ An internal error occurred.", + "dm-appr-title": "Your %label request got approved!", + "dm-appr-desc": "Your %label request got approved by %approver!\nYou are now on LoA until %endFmt.\nYou can view your LoA status by using the %viewCmd command.", + "dm-deny-title": "Your %label request was denied", + "dm-deny-desc": "Your %label request was denied by %denier.\n**Reason:** %reason", + "dm-ext-title": "Your %label got extended", + "dm-ext-desc": "Your %label got extended by %extender.\nThis extension is for **%days day(s)** - your %label now ends at %endFmt.\n**Reason for extension:** %reason\nYou can view your updated %label status by using the %viewCmd command.", + "dm-early-title": "Your %label ended early", + "dm-early-desc": "Your %label got ended early by %ender - your %label is now over and your role has been removed.\n**Reason for early end:** %reason.", + "dm-end-title": "Your %label has ended", + "dm-end-desc": "Your %label has now ended and your role has been removed.", + "log-start-title": "%label started for %username", + "log-start-desc": "%label started for %mention.%apprText", + "log-info-hdr": "%label Information", + "general-start": "Start", + "general-end": "End", + "log-end-title": "%label ended for %username", + "log-end-desc": "%label ended for %mention.", + "general-started": "Started", + "general-ended": "Ended", + "log-adj-title": "%label adjusted for %username", + "log-adj-desc": "The %label of %mention was adjusted by <@%executor>.", + "log-changes": "Changes made:", + "err-feat-disabled": "❌ %feature disabled.", + "err-use-susp": "❌ Please use `/staff-management infraction suspend`.", + "err-inv-dur": "❌ Invalid duration format or value.", + "label-never": "Never", + "succ-infract": "✅ Issued **%type** (Case #%caseId) to %user.", + "label-days": "days", + "succ-susp": "✅ Issued Suspension (Case #%caseId) to %user for %duration.", + "err-no-case": "❌ Case #%caseId does not exist.", + "err-case-inact": "âš ī¸ Case #%caseId is inactive.", + "succ-void-fail": "✅ Case #%caseId voided, role restore failed.", + "succ-void": "✅ Voided Case #%caseId.", + "info-clean-rec": "â„šī¸ %username has a clean record.", + "rec-title": "Record: %username", + "icon-voided": "âšĒ", + "label-exp": "Expires", + "label-case": "Case", + "label-date": "Date", + "label-iss": "Issuer", + "err-role-hier": "❌ I cannot assign a role higher than my highest role.", + "err-add-role": "❌ Failed to add role: %e", + "succ-promo": "✅ Promoted %user to %role.", + "info-no-promo": "â„šī¸ No promotion history found for %username.", + "prom-hist-title": "Promotion History: %username", + "label-role": "Role", + "label-prom-by": "Promoted by", + "panel-title": "User Panel: %username", + "panel-desc": "Manage and view all data for the user %mention (%id).", + "panel-ph": "Select a category...", + "opt-over": "Overview", + "opt-act": "Activity Checks", + "opt-inf": "Infractions", + "opt-prom": "Promotions", + "opt-rev": "Reviews", + "opt-shi": "Shifts", + "opt-sta": "Status", + "opt-del": "Data Deletion", + "p-inf-title": "Infractions: %username", + "p-inf-desc": "Total: **%count**\n%types\n", + "info-none": "*None*", + "p-no-hist": "*No history on this page.*", + "p-prom-title": "Promotions: %username", + "p-prom-desc": "Total: **%count**\n", + "p-rev-title": "Reviews: %username", + "p-rev-desc": "Total: **%count**\nAverage rating: **%avg ⭐**\n", + "label-by": "by", + "p-sta-title": "Status: %username", + "p-sta-desc": "Total requests: **%count**\nActive: %active\n", + "p-act-title": "Activity Checks: %username", + "p-act-desc": "Responses: **%count**\n", + "label-chk": "Check on", + "label-end": "Ends", + "label-chan": "Channel", + "p-shi-title": "Shifts: %username", + "no-quota-configured": "No quota", + "duty-quota-met": "✅ Quota Met", + "duty-quota-failed": "❌ Quota Not Met", + "label-unranked": "Unranked", + "panel-shifts-desc": "**Total Shifts:** %totalShifts\n**Duration:** %totalSeconds\n**Rank:** %lbRank\n**Breakdown:**\n%breakdownStr\n\n%quotaStr", + "err-shift-data-unavailable": "Shift data unavailable: %error", + "btn-view-history": "View History", + "panel-deletion-title": "Data Deletion: %tag", + "panel-deletion-desc": "âš ī¸ DANGEROUS AREA âš ī¸\nYou are now entering a dangerous zone. At this place, you are able to delete specific or all data for the selected user. These actions ***CANNOT BE UNDONE*** and should only be used if you are absolutely sure about what you are doing. If you only want to delete specific entries, please use the respective command for that entry instead.\nIf you are unsure, click 'Go Back' from the dropdown now.\n\nUse the dropdown below to choose which data you want to delete or delete all data. Choose wisely and gracefully.", + "panel-deletion-placeholder": "Select data to delete...", + "panel-opt-back": "Go Back", + "panel-opt-del-act": "Delete Activity Checks", + "panel-opt-del-inf": "Delete Infractions", + "panel-opt-del-prom": "Delete Promotions", + "panel-opt-del-rev": "Delete Reviews", + "panel-opt-del-shifts": "Delete Shifts", + "panel-opt-del-status": "Delete Status", + "panel-opt-del-all": "Delete ALL data", + "status-active-loa": "đŸŸĸ On LoA", + "status-active-ra": "🟠 On RA", + "status-hist-loa": "LoA History", + "status-hist-ra": "RA History", + "err-status-disabled": "❌ %type system disabled.", + "err-invalid-duration": "❌ Invalid duration.", + "err-duration-max": "❌ Max duration is %max days.", + "err-status-exists": "❌ You have an active %type request.", + "status-request-title": "New %type Request", + "status-req-user": "User", + "status-req-duration": "Duration", + "btn-approve": "Approve", + "btn-deny": "Deny", + "success-status-request": "✅ %type request created (%state).", + "state-pending": "Pending", + "state-auto": "Auto-Approved", + "no-active-status": "â„šī¸ %user has no active %type.", + "label-stat": "Status", + "filter-active": " (Active)", + "filter-expired": " (Expired)", + "filter-history": " (History)", + "err-no-recs": "No records found.", + "manage-status-title": "Manage %label - %username", + "manage-stat-desc": "%status\nPrevious %label's: %count", + "no-act-stat": "âšĢ No active %label", + "manage-active-details": "📋 Active %label Details", + "label-auto": "Auto", + "manage-no-active-user": "No active %label.", + "btn-end-early": "End %label Early", + "btn-extend": "Extend %label", + "err-no-active-end": "❌ No active %label to end.", + "modal-end-early-title": "End %label Early", + "modal-end-early-reason": "Reason for ending", + "err-stat-inact": "❌ This %label is inactive.", + "status-ended-embed-desc": "âšĢ %label ended by %user\nReason: %reason", + "err-no-active-extend": "❌ No active %label.", + "modal-extend-title": "Extend %label", + "modal-extend-days": "Additional days, maximum of 180 days", + "modal-extend-reason": "Reason for extension", + "status-adjusted-log": "**%label extended** - the %label now ends at %newEnd.\n**Reason:** %reason", + "mod-stat-ext": "**Start:** %s\n**End:** %e (+%d days)\n**Status:** %t\n**Approved by:** %a\n**Reason:** %r", + "info-no-status-history": "â„šī¸ No %label history.", + "status-history-desc": "Showing %count of %total %label records.", + "err-ac-act": "❌ Active check already running.", + "err-ac-norole": "❌ No target roles configured.", + "err-ac-invchan": "❌ Invalid channel.", + "ac-confirm-btn": "Confirm Activity", + "succ-ac-start": "✅ Check started in <#%channel> for %hours hours.", + "err-ac-perms": "❌ Missing permissions in <#%channel>.", + "ac-title-end": "📋 Activity Check (Ended)", + "ac-res-title": "📊 Activity Results", + "ac-f-res": "✅ Responded (%count)", + "ac-f-fail": "❌ Failed (%count)", + "ac-f-exc": "đŸ›Ąī¸ Exceptions (%count)", + "err-not-mem": "❌ Not a member.", + "err-self-rate": "❌ Cannot rate yourself.", + "err-staff-rate": "❌ Can only rate staff.", + "succ-review": "✅ Rated %tag %stars stars.", + "rev-title": "Reviews: %username", + "rev-desc": "**Average:** %avg ⭐ (%count reviews)", + "label-hist": "History", + "info-ac-none": "There are no active activity checks. Please check recent results in %c.", + "log-sched-loa": "[Staff Management] Successfully scheduled %count active LoA/RA expirations.", + "log-sched-fail": "[Staff Management] Failed to init expiry schedules: %error", + "log-susp-end": "[Staff Management] Automatically ended suspension for %tag", + "log-susp-err": "[Staff Management] Error expiring suspension: %error", + "log-shift-leave": "[Staff Management] Auto-ended shift for user %tag (User left guild).", + "log-leave-err": "[Staff Management] Error handling member leave: %error", + "log-del-all": "[Staff Management] Data deletion (ALL) executed for user %target by admin %admin.", + "log-del-type": "[Staff Management] Data deletion (%type) executed for user %target by admin %admin.", + "log-int-error": "[Staff Management] Interaction Error: %error", + "log-void-all": "[Staff management] All shift data for the user with ID %target has been deleted by admin %admin.", + "log-add-time": "[Staff Management] %admin added %min mins of %type duty time to %target.", + "log-panel-shift-err": "[Staff Management] User panel error: %error", + "log-ac-auto": "[Staff Management] Automated activity check is being initiated.", + "log-stat-dm-error": "[Staff Management] Failed to send status DM to %u: %e", + "log-status-adj-error": "[Staff Management] Logging status adjustment failed: %e", + "log-promo-msg-error": "[Staff Management] Failed to send promotion announcement: %e", + "label-user": "User", + "label-dur": "Duration", + "btn-appr": "Approve", + "stat-pend": "Pending Approval", + "stat-auto": "Auto-Approved", + "filter-act": " (Active)", + "filter-exp": " (Expired)", + "filter-hist": " (History)", + "manage-stat-title": "Manage %l - %u", + "manage-stat-f1": "📋 Active %l Details", + "no-act-user": "This staff member does not have an active %l.", + "btn-ext": "Extend %l", + "err-no-act-end": "❌ No active %l to end.", + "modal-end-early": "End %l Early", + "label-reason-end": "Reason for ending early", + "err-stat-not-act": "❌ This %l is no longer active.", + "mod-stat-end-desc": "**Status:** âšĢ %l ended early by %u\n**Reason:** %r", + "err-no-act-ext": "❌ No active %l to extend.", + "modal-ext": "Extend %l", + "label-add-days": "Additional days (e.g. 3, 7, 14)", + "label-ext-reason": "Reason for extension", + "log-adj-text": "1. **%l extended:** it now ends at %n. Old end date: %o.\n2. **Reason:** %r", + "label-ext-by": "extended by %dd", + "info-no-stat-hist": "â„šī¸ This staff member has no %l history.", + "stat-hist-desc": "Showing %r of %c %l records.", + "err-no-user": "❌ Could not find that user.", + "btn-conf-act": "Confirm Activity", + "duty-hi-line": "Start: | End: ", + "err-gen": "❌ Error: %e", + "lbl-log-chan": "the configured log channel", + "ac-live-title": "Live Activity Check Status", + "lbl-ends": "Ends", + "del-conf-phr": "I understand that this will delete the specified data for this user and it cannot be undone.", + "err-ac-ended": "❌ This activity check has already ended.", + "err-ac-not-req": "❌ You are not required to respond to this activity check.", + "info-ac-alr": "â„šī¸ You have already confirmed your activity!", + "cmd-desc-status": "Manage Leave of Absence (LoA) and Reduced Activity (RA).", + "cmd-desc-loa": "Manage Leave of Absence (LoA).", + "cmd-desc-loa-request": "Request a Leave of Absence.", + "cmd-desc-loar-duration": "The duration for your LoA (e.g. 3d, 2w, 1m)", + "cmd-desc-loar-reason": "Reason for your LoA", + "cmd-desc-loa-view": "View your Leave of Absence status.", + "cmd-desc-loav-user": "The user to view the LoA status", + "cmd-desc-loa-list": "List of all Leave of Absences", + "cmd-desc-loal-filter": "Filter the LoA list on active, expired or all", + "cmd-desc-loa-admin": "Manage a user's Leave of Absence.", + "cmd-desc-loaa-user": "The user to manage their LoA", + "cmd-desc-ra": "Manage Reduced Activity (RA).", + "cmd-desc-ra-request": "Request Reduced Activity.", + "cmd-desc-rar-duration": "The duration for your RA (e.g. 3d, 2w, 1m)", + "cmd-desc-rar-reason": "Reason for your RA", + "cmd-desc-ra-view": "View your Reduced Activity status.", + "cmd-desc-rav-user": "The user to view the RA status", + "cmd-desc-ra-list": "List of all Reduced Activities", + "cmd-desc-ral-filter": "Filter the RA list on active, expired or all", + "cmd-desc-ra-admin": "Manage a user's Reduced Activity.", + "cmd-desc-raa-user": "The user to manage their RA", + "cmd-desc-duty": "Manage your duty status and view statistics.", + "cmd-desc-duty-manage": "Manage your duty status.", + "cmd-desc-duty-manage-type": "The duty type", + "cmd-desc-duty-active": "View all staff currently on duty.", + "cmd-desc-duty-history": "View your duty history.", + "cmd-desc-duty-lb": "View the duty time leaderboard.", + "cmd-desc-duty-lb-type": "The duty type for the leaderboard.", + "cmd-desc-duty-time": "View your total duty time.", + "cmd-desc-duty-time-type": "The duty type", + "cmd-desc-duty-admin": "Manage a user's shift.", + "cmd-desc-duty-admin-user": "The user to manage their shift", + "cmd-desc-smg": "Access the staff management system.", + "cmd-desc-panel": "Open the staff management panel for a user.", + "cmd-desc-panel-user": "The user to open the staff panel for.", + "cmd-desc-infractions": "Manage staff infractions.", + "cmd-desc-issue": "Issue an infraction to a staff member.", + "cmd-desc-issue-user": "The user receiving the infraction.", + "cmd-desc-issue-type": "The type of infraction to issue.", + "cmd-desc-issue-reason": "The reason for issuing this infraction.", + "cmd-desc-issue-expiry": "When the infraction should expire.", + "cmd-desc-suspend": "Suspend a staff member.", + "cmd-desc-suspend-user": "The user to suspend.", + "cmd-desc-suspend-duration": "How long the suspension should last.", + "cmd-desc-suspend-reason": "The reason for the suspension.", + "cmd-desc-history": "View a user's history.", + "cmd-desc-history-user": "The user whose history you want to view.", + "cmd-desc-void": "Void an infraction case.", + "cmd-desc-void-case-id": "The case ID of the infraction to void.", + "cmd-desc-promotion": "Manage staff promotions.", + "cmd-desc-promote": "Promote a staff member to a new rank.", + "cmd-desc-promote-user": "The user to promote.", + "cmd-desc-promote-rank": "The rank to promote the user to.", + "cmd-desc-promote-reason": "The reason for the promotion.", + "cmd-desc-promote-channel": "The channel to announce the promotion in.", + "cmd-desc-ac": "Manage activity checks.", + "cmd-desc-ac-start": "Start a new activity check.", + "cmd-desc-ac-start-channel": "The channel where the activity check will be posted.", + "cmd-desc-ac-view": "View the current activity check status.", + "cmd-desc-ac-end": "End the current activity check.", + "cmd-desc-profile": "Manage staff profiles.", + "cmd-desc-profile-view": "View a staff member's profile.", + "cmd-desc-profile-view-user": "The user whose profile you want to view.", + "cmd-desc-profile-edit": "Edit your staff profile.", + "cmd-desc-profile-wipe": "Wipe a staff member's profile data.", + "cmd-desc-profile-wipe-user": "The user whose profile will be wiped.", + "cmd-desc-review": "Manage staff reviews.", + "cmd-desc-submit": "Submit a review for a staff member.", + "cmd-desc-submit-user": "The user you are reviewing.", + "cmd-desc-submit-stars": "The star rating for the review.", + "cmd-desc-submit-comment": "Your review comment.", + "del-no-perm": "You do not have sufficient permissions to perform data deletion." } } diff --git a/modules/staff-management-system/commands/duty.js b/modules/staff-management-system/commands/duty.js new file mode 100644 index 0000000..194d37b --- /dev/null +++ b/modules/staff-management-system/commands/duty.js @@ -0,0 +1,1086 @@ +const { MessageFlags, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle } = require('discord.js'); +const { Op, fn, col, literal } = require('sequelize'); +const { getConfig, applyFooter, formatDuration, buildPaginationRow } = require('../staff-management'); +const { localize } = require('../../../src/functions/localize'); + +function getLookbackDate(config) { + const lookback = config.leaderboardLookback || 'Weekly'; + if (lookback === 'All-time') return null; + const date = new Date(); + if (lookback === 'Weekly') date.setDate(date.getDate() - 7); + else if (lookback === 'Monthly') date.setMonth(date.getMonth() - 1); + return date; +} + +function getQuotaForMember(member, config) { + if (!config.enableQuotas || !config.quotas || Object.keys(config.quotas).length === 0) return null; + + let bestQuota = null; + let highestPosition = -1; + + for (const [roleId, hoursStr] of Object.entries(config.quotas)) { + const hours = parseFloat(hoursStr); + if (isNaN(hours)) continue; + + const role = member.guild.roles.cache.get(roleId); + if (role && member.roles.cache.has(roleId) && role.position > highestPosition) { + highestPosition = role.position; + bestQuota = { roleId, hours }; + } + } + + return bestQuota; +} + +async function buildDutyManagePayload(client, userId, guild, shiftType) { + const Profile = client.models['staff-management-system']['StaffProfile']; + const Shift = client.models['staff-management-system']['StaffShift']; + + const user = await client.users.fetch(userId).catch(() => null); + const profile = await Profile.findByPk(userId); + + const onDuty = profile?.onDuty || false; + const onBreak = profile?.onBreak || false; + + let statusText, statusColor; + if (onDuty && onBreak) { statusText = localize('staff-management-system', 'stat-brk'); statusColor = 'Yellow'; } + else if (onDuty) { statusText = localize('staff-management-system', 'stat-on'); statusColor = 'Green'; } + else { statusText = localize('staff-management-system', 'stat-off'); statusColor = 'Red'; } + + const completedShifts = await Shift.findAll({ + where: { + userId, + type: shiftType, + endTime: { [Op.not]: null }, + duration: { [Op.not]: null } + } + }); + const totalShifts = completedShifts.length; + const totalSeconds = completedShifts.reduce((sum, s) => sum + (parseInt(s.duration) || 0), 0); + const avgSeconds = totalShifts > 0 + ? Math.floor(totalSeconds / totalShifts) + : 0; + + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'duty-panel-title', { type: shiftType })) + .setColor(statusColor) + .setThumbnail(user?.displayAvatarURL({ dynamic: true }) || null) + .setDescription(`**${user?.username || userId}**\n${statusText}`) + .addFields( + { + name: localize('staff-management-system', 'duty-stats'), + value: localize('staff-management-system', 'duty-stat-desc', + { + duration: formatDuration(totalSeconds), + count: totalShifts, + average: formatDuration(avgSeconds) + } + ) + } + ) + ); + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`duty-mgmt_start_${userId}_${shiftType}`) + .setLabel(localize('staff-management-system', 'btn-duty-on')) + .setStyle(ButtonStyle.Success) + .setDisabled(onDuty), + new ButtonBuilder() + .setCustomId(`duty-mgmt_break_${userId}`) + .setLabel(onBreak ? localize('staff-management-system', 'btn-duty-res') : localize('staff-management-system', 'btn-duty-brk')) + .setStyle(ButtonStyle.Secondary) + .setDisabled(!onDuty), + new ButtonBuilder() + .setCustomId(`duty-mgmt_end_${userId}`) + .setLabel(localize('staff-management-system', 'btn-duty-off')) + .setStyle(ButtonStyle.Danger) + .setDisabled(!onDuty) + ); + + return { + embeds: [embed.toJSON()], + components: [row.toJSON()] + }; +} + +async function buildDutyTimePayload(client, interaction, shiftType) { + const config = getConfig(client, 'shifts'); + const Shift = client.models['staff-management-system']['StaffShift']; + const user = interaction.user; + + const whereClause = { + userId: user.id, + endTime: { [Op.not]: null }, + duration: { [Op.not]: null } + }; + if (shiftType !== 'All') whereClause.type = shiftType; + + const shifts = await Shift.findAll({ where: whereClause }); + + const totalSeconds = shifts.reduce((sum, s) => sum + (parseInt(s.duration) || 0), 0); + const shiftCount = shifts.length; + + let breakdownText = ''; + if (shiftType === 'All' && shiftCount > 0) { + const grouped = {}; + for (const s of shifts) { + const t = s.type || 'Staff'; + grouped[t] = (grouped[t] || 0) + (parseInt(s.duration) || 0); + } + breakdownText = `\n\n**${localize('staff-management-system', 'duty-breakdown')}:**\n` + Object.entries(grouped) + .sort((a, b) => b[1] - a[1]) + .map(([t, sec]) => `â€ĸ ${t}: ${formatDuration(sec)}`) + .join('\n'); + } + + let quotaText = ''; + const member = await interaction.guild.members.fetch(user.id).catch(() => null); + if (member) { + const quota = getQuotaForMember(member, config); + if (quota) { + const timeframe = config.quotaTimeframe || 'Weekly'; + const cutoff = new Date(); + if (timeframe === 'Weekly') cutoff.setDate(cutoff.getDate() - 7); + else cutoff.setMonth(cutoff.getMonth() - 1); + + const recentWhere = { + userId: user.id, + startTime: { [Op.gt]: cutoff }, + endTime: { [Op.not]: null }, + duration: { [Op.not]: null } + }; + if (shiftType !== 'All') recentWhere.type = shiftType; + + const recentShifts = await Shift.findAll({ where: recentWhere }); + const recentSeconds = recentShifts.reduce((sum, s) => sum + (parseInt(s.duration) || 0), 0); + const requiredSeconds = quota.hours * 3600; + const metQuota = recentSeconds >= requiredSeconds; + quotaText = localize('staff-management-system', 'duty-quota-str', { + timeframe, + duration: formatDuration(recentSeconds), + hours: quota.hours, + result: metQuota + ? localize('staff-management-system', 'quota-met') + : localize('staff-management-system', 'quota-fail') + }); + } + } + + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'duty-time-title', { type: shiftType })) + .setColor('Blue') + .setThumbnail(user.displayAvatarURL({ dynamic: true })) + .setDescription(localize('staff-management-system', 'duty-time-desc', { + count: shiftCount, + duration: formatDuration(totalSeconds) + }) + breakdownText + quotaText) + ); + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`duty-mgmt_hist_${user.id}_1_${shiftType}`) + .setLabel(localize('staff-management-system', 'btn-hist')) + .setStyle(ButtonStyle.Secondary) + .setDisabled(shiftCount === 0) + ); + + return { + embeds: [embed.toJSON()], + components: [row.toJSON()] + }; +} + +async function buildLeaderboardPayload(client, page = 1, shiftType) { + const config = getConfig(client, 'shifts'); + const Shift = client.models['staff-management-system']['StaffShift']; + const limit = 15; + const offset = (page - 1) * limit; + + const whereClause = { + endTime: { [Op.not]: null }, + duration: { [Op.not]: null } + }; + if (shiftType !== 'All') whereClause.type = shiftType; + + const lookbackDate = getLookbackDate(config); + if (lookbackDate) whereClause.startTime = { [Op.gt]: lookbackDate }; + + const allResults = await Shift.findAll({ + attributes: [ + 'userId', + [fn('SUM', col('duration')), 'totalDuration'], + [fn('COUNT', col('id')), 'shiftCount'] + ], + where: whereClause, + group: ['userId'], + order: [[literal('totalDuration'), 'DESC']] + }); + + const total = allResults.length; + if (total === 0) return { + content: localize('staff-management-system', 'err-no-lb', { + type: shiftType + }) + }; + + const totalPages = Math.ceil(total / limit) || 1; + const paginated = allResults.slice(offset, offset + limit); + + const lines = []; + for (let i = 0; i < paginated.length; i++) { + const entry = paginated[i]; + const dur = formatDuration(parseInt(entry.dataValues.totalDuration)); + lines.push(`${offset + i + 1}. **<@${entry.userId}>** â€ĸ ${dur}`); + } + + const lookbackLabel = config.leaderboardLookback || 'Weekly'; + const embed = new EmbedBuilder() + .setTitle(localize('staff-management-system', 'duty-lb-title', { + type: shiftType + })) + .setColor('Gold') + .setDescription(localize('staff-management-system', 'duty-lb-desc', { + lookback: lookbackLabel, + lines: lines.join('\n') + })) + .setFooter({ text: localize('staff-management-system', 'page-count', { + page, + total: totalPages + }) + }); + + const row = buildPaginationRow( + `duty-mgmt_lb_${page - 1}_${shiftType}`, + 'duty_lb_count', + `duty-mgmt_lb_${page + 1}_${shiftType}`, + page, totalPages, 'back', 'next' + ); + + return { + embeds: [embed.toJSON()], + components: [row.toJSON()] + }; +} + +async function buildShiftHistoryPayload(client, userId, page = 1, shiftType) { + const Shift = client.models['staff-management-system']['StaffShift']; + const limit = 10; + const offset = (page - 1) * limit; + + const whereClause = { + userId, + endTime: { [Op.not]: null }, + duration: { [Op.not]: null } + }; + if (shiftType !== 'All') whereClause.type = shiftType; + + const { count, rows } = await Shift.findAndCountAll({ + where: whereClause, + order: [['startTime', 'DESC']], + limit, + offset + }); + + if (count === 0) return { content: localize('staff-management-system', 'info-no-sh-hi') }; + const totalPages = Math.ceil(count / limit) || 1; + + const lines = rows.map((shift, i) => { + const dur = formatDuration(shift.duration); + const startTs = Math.floor(new Date(shift.startTime).getTime() / 1000); + const endTs = Math.floor(new Date(shift.endTime).getTime() / 1000); + const typeBadge = shiftType === 'All' ? ` \`[${shift.type || 'Staff'}]\`` : ''; + + return `**${offset + i + 1}. ${dur}${typeBadge}:**\nStart: | End: `; + }); + + const embed = new EmbedBuilder() + .setTitle(localize('staff-management-system', 'duty-hi-title', { + type: shiftType + })) + .setColor('Blue') + .setDescription(lines.join('\n\n')) + .setFooter({ text: localize('staff-management-system', 'page-count', { + page, + total: totalPages + }) + }); + + const row = buildPaginationRow( + `duty-mgmt_hist_${userId}_${page - 1}_${shiftType}`, + 'duty_hist_count', + `duty-mgmt_hist_${userId}_${page + 1}_${shiftType}`, + page, totalPages + ); + + return { + embeds: [embed.toJSON()], + components: [row.toJSON()] + }; +} + +async function buildDutyAdminPayload(client, targetMember, requestingMember) { + const Profile = client.models['staff-management-system']['StaffProfile']; + const Shift = client.models['staff-management-system']['StaffShift']; + + const targetUser = targetMember.user; + const profile = await Profile.findByPk(targetUser.id); + + const onDuty = profile?.onDuty || false; + const onBreak = profile?.onBreak || false; + + let statusText, statusColor; + if (onDuty && onBreak) { + statusText = localize('staff-management-system', 'stat-brk'); + statusColor = 'Yellow'; + } + else if (onDuty) { + statusText = localize('staff-management-system', 'stat-on'); + statusColor = 'Green'; + } + else { + statusText = localize('staff-management-system', 'stat-off'); + statusColor = 'Red'; + } + + const completedShifts = await Shift.findAll({ + where: { + userId: targetUser.id, + endTime: { [Op.not]: null }, + duration: { [Op.not]: null } + } + }); + const totalShifts = completedShifts.length; + const totalSeconds = completedShifts.reduce((sum, s) => sum + (parseInt(s.duration) || 0), 0); + const avgSeconds = totalShifts > 0 + ? Math.floor(totalSeconds / totalShifts) + : 0; + + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'duty-adm-title', { + user: targetUser.username + })) + .setColor(statusColor) + .setThumbnail(targetUser.displayAvatarURL({ dynamic: true })) + .setDescription(`**${targetUser.username}**\n${statusText}`) + .addFields( + { + name: localize('staff-management-system', 'duty-stats'), + value: localize('staff-management-system', 'duty-stat-desc', { + duration: formatDuration(totalSeconds), + count: totalShifts, + average: formatDuration(avgSeconds) + }) + } + ) + ); + + const generalConfig = client.configurations['staff-management-system']['configuration']; + const isManagement = requestingMember.roles.cache.some(r => (generalConfig.managementRoles || []).includes(r.id)) || requestingMember.permissions.has('Administrator'); + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`duty-mgmt_admin-forceend_${targetUser.id}`) + .setLabel(localize('staff-management-system', 'btn-f-off')) + .setEmoji('🔴') + .setStyle(ButtonStyle.Danger) + .setDisabled(!onDuty), + new ButtonBuilder() + .setCustomId(`duty-mgmt_admin-voidactive_${targetUser.id}`) + .setLabel(localize('staff-management-system', 'btn-v-act')) + .setEmoji('đŸ—‘ī¸') + .setStyle(ButtonStyle.Danger) + .setDisabled(!onDuty), + new ButtonBuilder() + .setCustomId(`duty-mgmt_admin-addtime_${targetUser.id}`) + .setLabel(localize('staff-management-system', 'btn-add-t')) + .setEmoji('âąī¸') + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(`duty-mgmt_admin-voidall_${targetUser.id}`) + .setLabel(localize('staff-management-system', 'btn-v-all')) + .setEmoji('âš ī¸') + .setStyle(ButtonStyle.Danger) + .setDisabled(!isManagement) + ); + + return { + embeds: [embed.toJSON()], + components: [row.toJSON()] + }; +} + +// ----- Button handlers ----- +async function handleDutyStartButton(client, interaction) { + const parts = interaction.customId.split('_'); + const userId = parts[2]; + const shiftType = parts[3] || 'Staff'; + + if (interaction.user.id !== userId) return interaction.followUp({ + content: localize('staff-management-system', 'err-not-yours'), + flags: MessageFlags.Ephemeral + }); + + const config = getConfig(client, 'shifts'); + const Profile = client.models['staff-management-system']['StaffProfile']; + const Shift = client.models['staff-management-system']['StaffShift']; + + const profile = await Profile.findByPk(userId); + if (profile?.onDuty) return interaction.followUp({ + content: localize('staff-management-system', 'err-alr-on'), + flags: MessageFlags.Ephemeral + }); + + await Shift.create({ + userId, + startTime: new Date(), + type: shiftType + }); + await Profile.upsert({ + userId, + onDuty: true, + onBreak: false, + lastClockIn: new Date() + }); + + if (config.onDutyRole) { + const member = await interaction.guild.members.fetch(userId).catch(() => null); + if (member) await member.roles.add(config.onDutyRole).catch(() => {}); + } + + const payload = await buildDutyManagePayload(client, userId, interaction.guild, shiftType); + return interaction.editReply(payload); +} + +async function handleDutyBreakButton(client, interaction) { + const userId = interaction.customId.split('_')[2]; + if (interaction.user.id !== userId) return interaction.followUp({ + content: localize('staff-management-system', 'err-not-yours'), + flags: MessageFlags.Ephemeral + }); + + const Profile = client.models['staff-management-system']['StaffProfile']; + const Shift = client.models['staff-management-system']['StaffShift']; + const profile = await Profile.findByPk(userId); + + if (!profile?.onDuty) return interaction.followUp({ + content: localize('staff-management-system', 'err-not-on'), + flags: MessageFlags.Ephemeral + }); + + const activeShift = await Shift.findOne({ + where: { userId, endTime: null } + }); + const shiftType = activeShift?.type || 'Staff'; + + const nowOnBreak = !profile.onBreak; + await Profile.update({ + onBreak: nowOnBreak, + breakStartTime: nowOnBreak + ? new Date() + : null }, { + where: { userId } + } + ); + + const payload = await buildDutyManagePayload(client, userId, interaction.guild, shiftType); + return interaction.editReply(payload); +} + +async function handleDutyEndButton(client, interaction) { + const userId = interaction.customId.split('_')[2]; + if (interaction.user.id !== userId) return interaction.followUp({ + content: localize('staff-management-system', 'err-not-yours'), + flags: MessageFlags.Ephemeral + }); + + const config = getConfig(client, 'shifts'); + const Profile = client.models['staff-management-system']['StaffProfile']; + const Shift = client.models['staff-management-system']['StaffShift']; + + const profile = await Profile.findByPk(userId); + if (!profile?.onDuty) return interaction.followUp({ + content: localize('staff-management-system', 'err-not-on'), + flags: MessageFlags.Ephemeral + }); + + const activeShifts = await Shift.findAll({ where: { userId, endTime: null } }); + const shiftType = activeShifts.length > 0 ? activeShifts[0].type : 'Staff'; + + for (const activeShift of activeShifts) { + const endTime = new Date(); + const durationSeconds = Math.floor((endTime.getTime() - new Date(activeShift.startTime).getTime()) / 1000); + + if (config.minShiftDuration && (durationSeconds / 60) < config.minShiftDuration) { + await activeShift.destroy(); + } else { + await activeShift.update({ endTime, duration: durationSeconds }); + } + } + + await Profile.update({ + onDuty: false, + onBreak: false, + breakStartTime: null }, { + where: { userId } + }); + if (config.onDutyRole) { + const member = await interaction.guild.members.fetch(userId).catch(() => null); + if (member) await member.roles.remove(config.onDutyRole).catch(() => {}); + } + + const payload = await buildDutyManagePayload(client, userId, interaction.guild, shiftType); + return interaction.editReply(payload); +} + +async function handleDutyHistPageButton(client, interaction) { + const parts = interaction.customId.split('_'); + const userId = parts[2]; + const page = parseInt(parts[3]); + const shiftType = parts[4] || 'Staff'; + + if (interaction.user.id !== userId) return interaction.followUp({ + content: localize('staff-management-system', 'err-hist-oth'), + flags: MessageFlags.Ephemeral + }); + + const payload = await buildShiftHistoryPayload(client, userId, page, shiftType); + if (payload.content) return interaction.followUp({ + ...payload, + flags: MessageFlags.Ephemeral + }); + + const isOnHistEmbed = interaction.message?.embeds?.[0]?.title?.startsWith(localize('staff-management-system', 'duty-hi-title', { type: '' }).replace(' - ', '')); + if (isOnHistEmbed) { + return interaction.editReply(payload); + } else { + return interaction.followUp({ + ...payload, + flags: MessageFlags.Ephemeral + }); + } +} + +async function handleDutyLbPageButton(client, interaction) { + const parts = interaction.customId.split('_'); + const page = parseInt(parts[2]); + const shiftType = parts[3] || 'Staff'; + + const payload = await buildLeaderboardPayload(client, page, shiftType); + if (payload.content) return interaction.editReply({ ...payload, flags: MessageFlags.Ephemeral }); + return interaction.editReply(payload); +} + +// ----- Admin handler ----- +async function handleDutyAdminForceEnd(client, interaction) { + const targetUserId = interaction.customId.split('_')[2]; + const config = getConfig(client, 'shifts'); + const Profile = client.models['staff-management-system']['StaffProfile']; + const Shift = client.models['staff-management-system']['StaffShift']; + + const activeShifts = await Shift.findAll({ + where: { userId: targetUserId, endTime: null } + }); + for (const activeShift of activeShifts) { + const endTime = new Date(); + const durationSeconds = Math.floor((endTime.getTime() - new Date(activeShift.startTime).getTime()) / 1000); + await activeShift.update({ + endTime, + duration: durationSeconds + }); + } + + await Profile.update({ + onDuty: false, + onBreak: false, + breakStartTime: null }, { + where: { userId: targetUserId } + }); + if (config.onDutyRole) { + const member = await interaction.guild.members.fetch(targetUserId).catch(() => null); + if (member) await member.roles.remove(config.onDutyRole).catch(() => {}); + } + + const targetMember = await interaction.guild.members.fetch(targetUserId).catch(() => null); + const payload = await buildDutyAdminPayload(client, targetMember, interaction.member); + return interaction.editReply(payload); +} + +async function handleDutyAdminVoidActive(client, interaction) { + const targetUserId = interaction.customId.split('_')[2]; + const config = getConfig(client, 'shifts'); + const Profile = client.models['staff-management-system']['StaffProfile']; + const Shift = client.models['staff-management-system']['StaffShift']; + + const activeShifts = await Shift.findAll({ + where: { userId: targetUserId, endTime: null } + }); + for (const activeShift of activeShifts) await activeShift.destroy(); + + await Profile.update({ + onDuty: false, + onBreak: false, + breakStartTime: null }, { + where: { userId: targetUserId } + }); + if (config.onDutyRole) { + const member = await interaction.guild.members.fetch(targetUserId).catch(() => null); + if (member) await member.roles.remove(config.onDutyRole).catch(() => {}); + } + + const targetMember = await interaction.guild.members.fetch(targetUserId).catch(() => null); + const payload = await buildDutyAdminPayload(client, targetMember, interaction.member); + return interaction.editReply(payload); +} + +async function handleDutyAdminVoidAll(client, interaction) { + const targetUserId = interaction.customId.split('_')[2]; + const modal = new ModalBuilder() + .setCustomId(`duty-mgmt_admin-voidall-submit_${targetUserId}`) + .setTitle(localize('staff-management-system', 'mod-v-all-title')); + modal.addComponents( + new ActionRowBuilder() + .addComponents(new TextInputBuilder() + .setCustomId('confirm') + .setLabel(localize('staff-management-system', 'mod-v-all-lbl')) + .setStyle(TextInputStyle.Short) + .setPlaceholder('CONFIRM') + .setRequired(true)) + ); + return interaction.showModal(modal); +} + +async function handleDutyAdminVoidAllSubmit(client, interaction) { + const targetUserId = interaction.customId.split('_')[2]; + + if (interaction.fields.getTextInputValue('confirm') !== 'CONFIRM') { + return interaction.reply({ + content: localize('staff-management-system', 'err-conf-fail'), + flags: MessageFlags.Ephemeral + }); + } + + const config = getConfig(client, 'shifts'); + const Profile = client.models['staff-management-system']['StaffProfile']; + const Shift = client.models['staff-management-system']['StaffShift']; + + await Shift.destroy({ + where: { userId: targetUserId } + }); + await Profile.update({ + onDuty: false, + onBreak: false, + breakStartTime: null + }, { + where: { userId: targetUserId } + }); + + if (config.onDutyRole) { + const member = await interaction.guild.members.fetch(targetUserId).catch(() => null); + if (member) await member.roles.remove(config.onDutyRole).catch(() => {}); + } + + client.logger.info(localize('staff-management-system', 'log-void-all', { + target: targetUserId, + admin: interaction.user.id + })); + + return interaction.reply({ + content: localize('staff-management-system', 'succ-v-all', { user: targetUserId }), + flags: MessageFlags.Ephemeral + }); +} + +async function handleDutyAdminAddTimeButton(client, interaction) { + const targetUserId = interaction.customId.split('_')[2]; + const config = getConfig(client, 'shifts'); + const dutyTypes = config.dutyTypes && config.dutyTypes.length > 0 + ? config.dutyTypes + : ['Staff']; + + const modal = new ModalBuilder() + .setCustomId(`duty-mgmt_admin-addtime-submit_${targetUserId}`) + .setTitle(localize('staff-management-system', 'mod-add-t')); + modal.addComponents( + new ActionRowBuilder() + .addComponents( + new TextInputBuilder() + .setCustomId('minutes') + .setLabel(localize('staff-management-system', 'mod-add-min')) + .setStyle(TextInputStyle.Short) + .setPlaceholder('e.g. 60') + .setRequired(true) + ), + new ActionRowBuilder() + .addComponents( + new TextInputBuilder() + .setCustomId('type') + .setLabel(localize('staff-management-system', 'mod-add-type')) + .setStyle(TextInputStyle.Short) + .setPlaceholder(dutyTypes.join(', ')) + .setValue(dutyTypes[0]) + .setRequired(true) + ) + ); + return interaction.showModal(modal); +} + +async function handleDutyAdminAddTimeSubmit(client, interaction) { + const targetUserId = interaction.customId.split('_')[2]; + const minutesRaw = interaction.fields.getTextInputValue('minutes'); + const shiftType = interaction.fields.getTextInputValue('type'); + + const minutes = parseInt(minutesRaw); + if (isNaN(minutes) || minutes <= 0) { + return interaction.reply({ + content: localize('staff-management-system', 'err-inv-min'), + flags: MessageFlags.Ephemeral + }); + } + + const config = getConfig(client, 'shifts'); + const dutyTypes = config.dutyTypes && config.dutyTypes.length > 0 + ? config.dutyTypes + : ['Staff']; + + if (!dutyTypes.includes(shiftType)) { + return interaction.reply({ + content: localize('staff-management-system', 'err-inv-type', { + types: dutyTypes.join(', ') + }), + flags: MessageFlags.Ephemeral + }); + } + + const Shift = client.models['staff-management-system']['StaffShift']; + + const durationSeconds = minutes * 60; + const endTime = new Date(); + const startTime = new Date(endTime.getTime() - (durationSeconds * 1000)); + + await Shift.create({ + userId: targetUserId, + startTime: startTime, + endTime: endTime, + duration: durationSeconds, + type: shiftType + }); + + client.logger.info(localize('staff-management-system', 'log-add-time', { + admin: interaction.user.tag, + min: minutes, + type: shiftType, + target: targetUserId + })); + + const targetMember = await interaction.guild.members.fetch(targetUserId).catch(() => null); + const payload = await buildDutyAdminPayload(client, targetMember, interaction.member); + + return interaction.update(payload); +} + +// ----- Dropdown handler ----- +async function handleDutyDropdown(client, interaction, action, selectedType) { + if (action === 'manage') { + const payload = await buildDutyManagePayload(client, interaction.user.id, interaction.guild, selectedType); + return interaction.editReply({ content: '', ...payload }); + } + if (action === 'leaderboard') { + const payload = await buildLeaderboardPayload(client, 1, selectedType); + return interaction.editReply({ content: '', ...payload }); + } + if (action === 'time') { + const payload = await buildDutyTimePayload(client, interaction, selectedType); + return interaction.editReply({ content: '', ...payload }); + } +} + +async function handleCommonDutyCommand(i, action) { + const config = getConfig(i.client, 'shifts'); + if (!config || !config.enableShifts) return i.editReply({ content: localize('staff-management-system', 'err-sh-dis') }); + + const dutyTypes = config.dutyTypes && config.dutyTypes.length > 0 ? config.dutyTypes : ['Staff']; + let shiftType = i.options.getString('type'); + + const allowedTypes = (action === 'leaderboard' || action === 'time') ? ['All', ...dutyTypes] : dutyTypes; + + if (action === 'manage') { + const Profile = i.client.models['staff-management-system']['StaffProfile']; + const Shift = i.client.models['staff-management-system']['StaffShift']; + const profile = await Profile.findByPk(i.user.id); + if (profile?.onDuty) { + const activeShift = await Shift.findOne({ where: { userId: i.user.id, endTime: null } }); + shiftType = activeShift?.type || dutyTypes[0]; + } + } + + if (!shiftType) { + if (dutyTypes.length === 1 && action === 'manage') { + shiftType = dutyTypes[0]; + } else if (dutyTypes.length === 1 && (action === 'leaderboard' || action === 'time')) { + shiftType = 'All'; + } else { + const selectMenu = new StringSelectMenuBuilder() + .setCustomId(`duty-mgmt_dropdown_${action}`) + .setPlaceholder(localize('staff-management-system', 'ph-sel-type')); + + allowedTypes.forEach(t => selectMenu.addOptions({ label: t, value: t })); + const row = new ActionRowBuilder().addComponents(selectMenu); + return i.editReply({ content: localize('staff-management-system', 'msg-sel-type'), components: [row.toJSON()] }); + } + } else if (!allowedTypes.includes(shiftType)) { + return i.editReply({ content: localize('staff-management-system', 'err-inv-type', { types: allowedTypes.join(', ') }) }); + } + + if (action === 'manage') { + const payload = await buildDutyManagePayload(i.client, i.user.id, i.guild, shiftType); + await i.editReply(payload); + } else if (action === 'leaderboard') { + const payload = await buildLeaderboardPayload(i.client, 1, shiftType); + await i.editReply(payload); + } else if (action === 'time') { + const payload = await buildDutyTimePayload(i.client, i, shiftType); + await i.editReply(payload); + } +} + +module.exports.autoComplete = { + 'manage': { + 'type': async function (interaction) { + const config = getConfig(interaction.client, 'shifts'); + const dutyTypes = config.dutyTypes && config.dutyTypes.length > 0 + ? config.dutyTypes + : ['Staff']; + const focusedValue = interaction.value || ''; + + const filtered = dutyTypes.filter(choice => choice.toLowerCase().startsWith(focusedValue.toLowerCase())); + await interaction.respond(filtered.slice(0, 25).map(choice => ({ + name: choice, + value: choice + }))); + } + }, + 'leaderboard': { + 'type': async function (interaction) { + const config = getConfig(interaction.client, 'shifts'); + const dutyTypes = config.dutyTypes && config.dutyTypes.length > 0 + ? config.dutyTypes + : ['Staff']; + const options = ['All', ...dutyTypes]; + const focusedValue = interaction.value || ''; + + const filtered = options.filter(choice => choice.toLowerCase().startsWith(focusedValue.toLowerCase())); + await interaction.respond(filtered.slice(0, 25).map(choice => ({ + name: choice, + value: choice + }))); + } + }, + 'time': { + 'type': async function (interaction) { + const config = getConfig(interaction.client, 'shifts'); + const dutyTypes = config.dutyTypes && config.dutyTypes.length > 0 + ? config.dutyTypes + : ['Staff']; + const options = ['All', ...dutyTypes]; + const focusedValue = interaction.value || ''; + + const filtered = options.filter(choice => choice.toLowerCase().startsWith(focusedValue.toLowerCase())); + await interaction.respond(filtered.slice(0, 25).map(choice => ({ + name: choice, + value: choice + }))); + } + } +}; + +module.exports.beforeSubcommand = async function (interaction) { + await interaction.deferReply({ + flags: MessageFlags.Ephemeral + }); +}; + +module.exports.subcommands = { + 'manage': async function (i) { + await handleCommonDutyCommand(i, 'manage'); + }, + 'active': async function (i) { + const config = getConfig(i.client, 'shifts'); + if (!config || !config.enableShifts) return i.editReply({ + content: localize('staff-management-system', 'err-sh-dis') + }); + + const Shift = i.client.models['staff-management-system']['StaffShift']; + const activeShifts = await Shift.findAll({ + where: { endTime: null }, + order: [['startTime', 'ASC']] + }); + + if (activeShifts.length === 0) return i.editReply({ + content: localize('staff-management-system', 'info-no-act-sh') + }); + + const dutyTypes = config.dutyTypes && config.dutyTypes.length > 0 + ? config.dutyTypes + : ['Staff']; + + const grouped = {}; + for (const shift of activeShifts) { + const type = shift.type || dutyTypes[0]; + if (!grouped[type]) grouped[type] = []; + grouped[type].push(shift); + } + + const embed = applyFooter(i.client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'duty-act-title')) + .setColor('Green') + .setDescription(localize('staff-management-system', 'duty-act-desc', { + count: activeShifts.length + })) + ); + + let index = 1; + for (const type of dutyTypes) { + if (grouped[type]) { + const lines = []; + for (const shift of grouped[type]) { + const elapsed = Math.floor((Date.now() - new Date(shift.startTime).getTime()) / 1000); + lines.push(`${index}. **<@${shift.userId}>** â€ĸ ${formatDuration(elapsed)}`); + index++; + } + embed.addFields({ + name: `${type} (${grouped[type].length})`, + value: lines.join('\n') + }); + delete grouped[type]; + } + } + for (const [type, shifts] of Object.entries(grouped)) { + const lines = []; + for (const shift of shifts) { + const elapsed = Math.floor((Date.now() - new Date(shift.startTime).getTime()) / 1000); + lines.push(`${index}. **<@${shift.userId}>** â€ĸ ${formatDuration(elapsed)}`); + index++; + } + embed.addFields({ + name: `${type} (${shifts.length}) [Legacy]`, + value: lines.join('\n') + }); + } + await i.editReply({ + embeds: [embed.toJSON()] + }); + }, + 'leaderboard': async function (i) { + await handleCommonDutyCommand(i, 'leaderboard'); + }, + 'time': async function (i) { + await handleCommonDutyCommand(i, 'time'); + }, + 'admin': async function (i) { + const config = getConfig(i.client, 'shifts'); + if (!config || !config.enableShifts) return i.editReply({ + content: localize('staff-management-system', 'err-sh-dis') + }); + + const generalConfig = getConfig(i.client, 'configuration'); + const canManage = i.member.roles.cache.some(r => [...(generalConfig.supervisorRoles || []), ...(generalConfig.managementRoles || [])].includes(r.id)) || i.member.permissions.has('Administrator'); + if (!canManage) return i.editReply({ + content: localize('staff-management-system', 'err-no-perm') + }); + + const target = i.options.getMember('user'); + if (!target) return i.editReply({ + content: localize('staff-management-system', 'err-no-mem') + }); + + const payload = await buildDutyAdminPayload(i.client, target, i.member); + await i.editReply(payload); + } +}; + +module.exports.config = { + name: 'duty', + description: localize('staff-management-system', 'cmd-desc-duty'), + usage: '/duty', + type: 'slash', + defaultPermission: false, + options: [ + { + type: 'SUB_COMMAND', + name: 'manage', + description: localize('staff-management-system', 'cmd-desc-duty-manage'), + options: [ + { + type: 'STRING', + name: 'type', + description: localize('staff-management-system', 'cmd-desc-duty-manage-type'), + required: false, + autocomplete: true + } + ] + }, + { + type: 'SUB_COMMAND', + name: 'active', + description: localize('staff-management-system', 'cmd-desc-duty-active') + }, + { + type: 'SUB_COMMAND', + name: 'leaderboard', + description: localize('staff-management-system', 'cmd-desc-duty-lb'), + options: [ + { + type: 'STRING', + name: 'type', + description: localize('staff-management-system', 'cmd-desc-duty-lb-type'), + required: false, + autocomplete: true + } + ] + }, + { + type: 'SUB_COMMAND', + name: 'time', + description: localize('staff-management-system', 'cmd-desc-duty-time'), + options: [ + { + type: 'STRING', + name: 'type', + description: localize('staff-management-system', 'cmd-desc-duty-time-type'), + required: false, + autocomplete: true + } + ] + }, + { + type: 'SUB_COMMAND', + name: 'admin', + description: localize('staff-management-system', 'cmd-desc-duty-admin'), + options: [ + { + type: 'USER', + name: 'user', + description: localize('staff-management-system', 'cmd-desc-duty-admin-user'), + required: true + } + ] + } + ] +}; + +// Export handlers +module.exports.buttonHandlers = { + handleDutyStartButton, + handleDutyAdminAddTimeButton, + handleDutyBreakButton, + handleDutyEndButton, + handleDutyDropdown, + handleDutyHistPageButton, + handleDutyLbPageButton, + handleDutyAdminForceEnd, + handleDutyAdminVoidActive, + handleDutyAdminVoidAll, + handleDutyAdminVoidAllSubmit, + handleDutyAdminAddTimeSubmit +}; \ No newline at end of file diff --git a/modules/staff-management-system/commands/staff-management.js b/modules/staff-management-system/commands/staff-management.js new file mode 100644 index 0000000..56c4cc0 --- /dev/null +++ b/modules/staff-management-system/commands/staff-management.js @@ -0,0 +1,717 @@ +const { MessageFlags, EmbedBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder } = require('discord.js'); +const { embedTypeV2 } = require('../../../src/functions/helpers'); +const { localize } = require('../../../src/functions/localize'); +const { + issueInfraction, + getInfractionHistory, + issueSuspension, + voidInfraction, + promoteUser, + getPromotionHistory, + submitReview, + getReviewHistory, + startActivityCheck, + endActivityCheckProcess, + generateUserPanel +} = require('../staff-management'); + +function canManageChecks(client, member) { + if (member.permissions.has('Administrator')) return true; + const config = client.configurations['staff-management-system']['configuration'] || {}; + const supRoles = config.supervisorRoles || []; + const mgmtRoles = config.managementRoles || []; + return member.roles.cache.some(r => supRoles.includes(r.id) || mgmtRoles.includes(r.id)); +} + +async function handleProfileView(client, interaction, targetUser) { + const config = client.configurations['staff-management-system']['profiles']; + if (!config || !config.enableProfiles) return interaction.editReply({ + content: localize('staff-management-system', 'err-prof-dis') + }); + + if (!config.profileEmbedMessage) { + return interaction.editReply({ + content: localize('staff-management-system', 'err-prof-cfg') + }); + } + + const user = targetUser || interaction.user; + const member = await interaction.guild.members.fetch(user.id).catch(() => null); + if (!member) return interaction.editReply({ + content: localize('staff-management-system', 'err-no-mem') + }); + + const restrictToStaff = config.onlyAllowStaffProfile !== false; + if (restrictToStaff) { + const generalConfig = client.configurations['staff-management-system']['configuration'] || {}; + + const staffRoles = Array.isArray(generalConfig.staffRoles) + ? generalConfig.staffRoles + : (generalConfig.staffRoles + ? [generalConfig.staffRoles] + : [] + ); + const supRoles = Array.isArray(generalConfig.supervisorRoles) + ? generalConfig.supervisorRoles + : (generalConfig.supervisorRoles + ? [generalConfig.supervisorRoles] + : [] + ); + const mgmtRoles = Array.isArray(generalConfig.managementRoles) + ? generalConfig.managementRoles + : (generalConfig.managementRoles + ? [generalConfig.managementRoles] + : [] + ); + + const allStaffRoles = [...staffRoles, ...supRoles, ...mgmtRoles]; + const isAdmin = member.permissions.has('Administrator'); + const isStaff = allStaffRoles.length > 0 && member.roles.cache.some(r => allStaffRoles.includes(r.id)); + + if (!isAdmin && !isStaff) { + if (user.id === interaction.user.id) { + return interaction.editReply({ + content: localize('staff-management-system', 'err-prof-no-own') + }); + } else { + return interaction.editReply({ + content: localize('staff-management-system', 'err-prof-no-tgt') + }); + } + } + } + + const Profile = client.models['staff-management-system']['StaffProfile']; + const Review = client.models['staff-management-system']['StaffReview']; + + const [profile] = await Profile.findOrCreate({ + where: { userId: user.id } + }); + + const reviewsConfig = client.configurations['staff-management-system']['reviews']; + const reviewsEnabled = reviewsConfig && reviewsConfig.enableReviews; + + let ratingDisplay = localize('staff-management-system', 'rev-dis-text'); + if (reviewsEnabled) { + let avgRatingText = localize('staff-management-system', 'rev-no-rate'); + const allReviews = await Review.findAll({ + where: { targetId: user.id }, + attributes: ['stars'] + }); + if (allReviews.length > 0) { + avgRatingText = (allReviews.reduce((a, b) => a + b.stars, 0) / allReviews.length).toFixed(1); + } + ratingDisplay = `⭐ ${avgRatingText}`; + } + + let discordStatus = localize('staff-management-system', 'stat-offl'); + if (member.presence) { + switch (member.presence.status) { + case 'online': discordStatus = localize('staff-management-system', 'stat-onl'); break; + case 'idle': discordStatus = localize('staff-management-system', 'stat-idl'); break; + case 'dnd': discordStatus = localize('staff-management-system', 'stat-dnd'); break; + case 'offline': discordStatus = localize('staff-management-system', 'stat-offl'); break; + } + } + + const statusLines = [discordStatus]; + if (profile.onDuty) statusLines.push(localize('staff-management-system', 'stat-prof-ond')); + if (profile.activityStatus === 'LOA') statusLines.push(localize('staff-management-system', 'stat-prof-loa')); + if (profile.activityStatus === 'RA') statusLines.push(localize('staff-management-system', 'stat-prof-ra')); + + const introText = profile.customIntro || localize('staff-management-system', 'prof-no-intro'); + const nicknameText = profile.customNickname || user.username; + + const placeholders = { + '%user%': user.toString(), + '%username%': user.username, + '%nickname%': nicknameText, + '%intro%': introText, + '%status%': statusLines.join('\n'), + '%rating%': ratingDisplay, + '%pfp%': user.displayAvatarURL({ + dynamic: true, + format: 'png', + size: 1024 + }) || '' + }; + + let embedTemplate = config.profileEmbedMessage; + if (typeof embedTemplate === 'string') { + try { embedTemplate = JSON.parse(embedTemplate); } catch (e) {} + } + + let msgOpts = await embedTypeV2(embedTemplate, placeholders); + + if (!msgOpts || (!msgOpts.content && (!msgOpts.embeds || msgOpts.embeds.length === 0))) { + return interaction.editReply({ + content: localize('staff-management-system', 'err-prof-empty') + }); + } + + await interaction.editReply(msgOpts); +} + +async function handleProfileEdit(client, interaction) { + const config = client.configurations['staff-management-system']['profiles']; + if (!config || !config.enableProfiles) return interaction.reply({ + content: localize('staff-management-system', 'err-prof-dis'), + flags: MessageFlags.Ephemeral + }); + + const restrictToStaff = config.onlyAllowStaffProfile !== false; + if (restrictToStaff) { + const generalConfig = client.configurations['staff-management-system']['configuration'] || {}; + + const staffRoles = Array.isArray(generalConfig.staffRoles) + ? generalConfig.staffRoles + : (generalConfig.staffRoles + ? [generalConfig.staffRoles] + : [] + ); + const supRoles = Array.isArray(generalConfig.supervisorRoles) + ? generalConfig.supervisorRoles + : (generalConfig.supervisorRoles + ? [generalConfig.supervisorRoles] + : [] + ); + const mgmtRoles = Array.isArray(generalConfig.managementRoles) + ? generalConfig.managementRoles + : (generalConfig.managementRoles + ? [generalConfig.managementRoles] + : [] + ); + + const allStaffRoles = [ + ...staffRoles, + ...supRoles, + ...mgmtRoles + ]; + + const isAdmin = interaction.member.permissions.has('Administrator'); + const hasStaffRole = allStaffRoles.length > 0 && interaction.member.roles.cache.some(r => allStaffRoles.includes(r.id)); + + if (!isAdmin && !hasStaffRole) { + return interaction.reply({ + content: localize('staff-management-system', 'err-prof-perm'), + flags: MessageFlags.Ephemeral + }); + } + } + + const Profile = client.models['staff-management-system']['StaffProfile']; + const profile = await Profile.findByPk(interaction.user.id); + + const modal = new ModalBuilder() + .setCustomId(`staff-mgmt_profile-edit`) + .setTitle(localize('staff-management-system', 'prof-edit-title')); + + modal.addComponents( + new ActionRowBuilder() + .addComponents( + new TextInputBuilder() + .setCustomId('nickname') + .setLabel(localize('staff-management-system', 'prof-edit-nick')) + .setStyle(TextInputStyle.Short) + .setRequired(false) + .setValue(profile?.customNickname || '') + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('intro') + .setLabel(localize('staff-management-system', 'prof-edit-intro')) + .setStyle(TextInputStyle.Paragraph) + .setRequired(false) + .setValue(profile?.customIntro || '') + ) + ); + + return interaction.showModal(modal); +} + +async function handleProfileAdminWipe(client, interaction, targetUser) { + const profilesConfig = client.configurations['staff-management-system']['profiles']; + const generalConfig = client.configurations['staff-management-system']['configuration'] || {}; + + if (!profilesConfig || !profilesConfig.enableProfiles) { + return interaction.editReply({ + content: localize('staff-management-system', 'err-prof-dis') + }); + } + + const mRoles = Array.isArray(generalConfig.managementRoles) + ? generalConfig.managementRoles + : (generalConfig.managementRoles + ? [generalConfig.managementRoles] + : [] + ); + const sRoles = Array.isArray(generalConfig.supervisorRoles) + ? generalConfig.supervisorRoles + : (generalConfig.supervisorRoles + ? [generalConfig.supervisorRoles] + : [] + ); + + const requiredRoles = profilesConfig.managePermission === 'Management' + ? mRoles + : [...sRoles, ...mRoles]; + + const isAdmin = interaction.member.permissions.has('Administrator'); + const hasRequiredRole = requiredRoles.length > 0 && interaction.member.roles.cache.some(r => requiredRoles.includes(r.id)); + + if (!isAdmin && !hasRequiredRole) { + return interaction.editReply({ + content: localize('staff-management-system', 'err-no-perm') + }); + } + + const Profile = client.models['staff-management-system']['StaffProfile']; + await Profile.update({ + customNickname: null, + customIntro: null + }, + { + where: { userId: targetUser.id } + }); + + await interaction.editReply({ + content: localize('staff-management-system', 'succ-prof-wipe', { u: targetUser.username }) + }); +} + +module.exports.autoComplete = { + 'infraction': { + 'issue': { + 'type': async function (interaction) { + const config = interaction.client.configurations['staff-management-system']['infractions'] || {}; + const types = config.infractionTypes && config.infractionTypes.length > 0 + ? config.infractionTypes + : ['Warning', 'Strike']; + + const focusedValue = interaction.options.getFocused() || ''; + const filtered = types.filter(choice => choice.toLowerCase().startsWith(focusedValue.toLowerCase())); + await interaction.respond(filtered.slice(0, 25).map(choice => ({ name: choice, value: choice }))); + } + } + } +}; + +module.exports.subcommands = { + 'panel': async (i) => { + const user = i.options.getUser('user'); + const payload = await generateUserPanel(i.client, user); + await i.reply({ + ...payload, + flags: MessageFlags.Ephemeral + }); + }, + 'infraction': { + 'issue': async (i) => { + const user = i.options.getMember('user'); + const type = i.options.getString('type'); + const reason = i.options.getString('reason'); + const expiry = i.options.getString('expiry'); + await issueInfraction(i.client, i, user, type, reason, expiry); + }, + 'suspend': async (i) => { + const user = i.options.getMember('user'); + const duration = i.options.getString('duration'); + const reason = i.options.getString('reason'); + await issueSuspension(i.client, i, user, duration, reason); + }, + 'history': async (i) => { + const user = i.options.getUser('user'); + await getInfractionHistory(i.client, i, user); + }, + 'void': async (i) => { + const caseId = i.options.getInteger('case_id'); + await voidInfraction(i.client, i, caseId); + } + }, + 'promotion': { + 'promote': async (i) => { + const user = i.options.getMember('user'); + const role = i.options.getRole('rank'); + const reason = i.options.getString('reason'); + await promoteUser(i.client, i, user, role, reason); + }, + 'history': async (i) => { + const user = i.options.getUser('user'); + await getPromotionHistory(i.client, i, user); + } + }, + 'activity-check': { + 'start': async (i) => { + await i.deferReply({ flags: MessageFlags.Ephemeral }); + if (!canManageChecks(i.client, i.member)) return i.editReply({ + content: localize('staff-management-system', 'err-no-perm') + }); + await startActivityCheck(i.client, i, false); + }, + 'view': async (i) => { + await i.deferReply({ flags: MessageFlags.Ephemeral }); + if (!canManageChecks(i.client, i.member)) return i.editReply({ + content: localize('staff-management-system', 'err-no-perm') + }); + + const ActivityCheck = i.client.models['staff-management-system']['ActivityCheck']; + const activeCheck = await ActivityCheck.findOne({ + where: { status: 'ACTIVE' } + }); + + if (!activeCheck) { + const config = i.client.configurations['staff-management-system']['activity-checks'] || {}; + const generalConfig = i.client.configurations['staff-management-system']['configuration'] || {}; + let logChannelId = config.logChannel; + if (!logChannelId || (Array.isArray(logChannelId) && logChannelId.length === 0)) logChannelId = generalConfig.generalLogChannel; + if (Array.isArray(logChannelId)) logChannelId = logChannelId[0]; + + const channelPing = logChannelId + ? `<#${logChannelId}>` + : localize('staff-management-system', 'lbl-log-chan'); + return i.editReply({ + content: localize('staff-management-system', 'info-ac-none', { c: channelPing }) + }); + } + + const responded = JSON.parse(activeCheck.respondedUsers || '[]'); + const embed = new EmbedBuilder() + .setTitle(localize('staff-management-system', 'ac-live-title')) + .setColor('Blue') + .setDescription(`**${localize('staff-management-system', 'general-ends')}:** \n**${localize('staff-management-system', 'general-chan')}:** <#${activeCheck.channelId}>\n**${localize('staff-management-system', 'ac-tot-res')}:** ${responded.length}`) + .setFooter({ + text: `${i.client.strings.footer}`, + iconURL: i.client.strings.footerImgUrl + }); + + if (!i.client.strings.disableFooterTimestamp) embed.setTimestamp(); + await i.editReply({ + embeds: [embed] + }); + }, + 'end': async (i) => { + await i.deferReply({ flags: MessageFlags.Ephemeral }); + if (!canManageChecks(i.client, i.member)) return i.editReply({ + content: localize('staff-management-system', 'err-no-perm') + }); + + const ActivityCheck = i.client.models['staff-management-system']['ActivityCheck']; + const activeCheck = await ActivityCheck.findOne({ where: { status: 'ACTIVE' } }); + + if (!activeCheck) return i.editReply({ + content: localize('staff-management-system', 'err-ac-noact') + }); + + await endActivityCheckProcess(i.client, activeCheck); + await i.editReply({ + content: localize('staff-management-system', 'succ-ac-end') + }); + } + }, + 'profile': { + 'view': async (i) => { + await i.deferReply({ + flags: MessageFlags.Ephemeral + }); + const user = i.options.getUser('user') || i.user; + await handleProfileView(i.client, i, user); + }, + 'edit': async (i) => { + await handleProfileEdit(i.client, i); + }, + 'wipe': async (i) => { + await i.deferReply({ + flags: MessageFlags.Ephemeral + }); + const user = i.options.getUser('user'); + await handleProfileAdminWipe(i.client, i, user); + } + }, + 'review': { + 'submit': async (i) => { + const user = i.options.getUser('user'); + const stars = i.options.getInteger('stars'); + const comment = i.options.getString('comment'); + await submitReview(i.client, i, user, stars, comment); + }, + 'history': async (i) => { + const user = i.options.getUser('user') || i.user; + await getReviewHistory(i.client, i, user); + } + } +}; + +module.exports.config = { + name: 'staff-management', + description: localize('staff-management-system', 'cmd-desc-smg'), + usage: '/staff-management', + type: 'slash', + defaultPermission: false, + options: [ + { + type: 'SUB_COMMAND', + name: 'panel', + description: localize('staff-management-system', 'cmd-desc-panel'), + options: [ + { + type: 'USER', + name: 'user', + description: localize('staff-management-system', 'cmd-desc-panel-user'), + required: true + } + ] + }, + { + type: 'SUB_COMMAND_GROUP', + name: 'infraction', + description: localize('staff-management-system', 'cmd-desc-infractions'), + options: [ + { + type: 'SUB_COMMAND', + name: 'issue', + description: localize('staff-management-system', 'cmd-desc-issue'), + options: [ + { + type: 'USER', + name: 'user', + description: localize('staff-management-system', 'cmd-desc-issue-user'), + required: true + }, + { + type: 'STRING', + name: 'type', + description: localize('staff-management-system', 'cmd-desc-issue-type'), + required: true, + autocomplete: true + }, + { + type: 'STRING', + name: 'reason', + description: localize('staff-management-system', 'cmd-desc-issue-reason'), + required: true + }, + { + type: 'STRING', + name: 'expiry', + description: localize('staff-management-system', 'cmd-desc-issue-expiry'), + required: false + } + ] + }, + { + type: 'SUB_COMMAND', + name: 'suspend', + description: localize('staff-management-system', 'cmd-desc-suspend'), + options: [ + { + type: 'USER', + name: 'user', + description: localize('staff-management-system', 'cmd-desc-suspend-user'), + required: true + }, + { + type: 'STRING', + name: 'duration', + description: localize('staff-management-system', 'cmd-desc-suspend-duration'), + required: true + }, + { + type: 'STRING', + name: 'reason', + description: localize('staff-management-system', 'cmd-desc-suspend-reason'), + required: true + } + ] + }, + { + type: 'SUB_COMMAND', + name: 'history', + description: localize('staff-management-system', 'cmd-desc-history'), + options: [ + { + type: 'USER', + name: 'user', + description: localize('staff-management-system', 'cmd-desc-history-user'), + required: true + } + ] + }, + { + type: 'SUB_COMMAND', + name: 'void', + description: localize('staff-management-system', 'cmd-desc-void'), + options: [ + { + type: 'INTEGER', + name: 'case_id', + description: localize('staff-management-system', 'cmd-desc-void-case-id'), + required: true + } + ] + } + ] + }, + { + type: 'SUB_COMMAND_GROUP', + name: 'promotion', + description: localize('staff-management-system', 'cmd-desc-promotion'), + options: [ + { + type: 'SUB_COMMAND', + name: 'promote', + description: localize('staff-management-system', 'cmd-desc-promote'), + options: [ + { + type: 'USER', + name: 'user', + description: localize('staff-management-system', 'cmd-desc-promote-user'), + required: true + }, + { + type: 'ROLE', + name: 'rank', + description: localize('staff-management-system', 'cmd-desc-promote-rank'), + required: true + }, + { + type: 'STRING', + name: 'reason', + description: localize('staff-management-system', 'cmd-desc-promote-reason'), + required: false + }, + { + type: 'CHANNEL', + name: 'channel', + description: localize('staff-management-system', 'cmd-desc-promote-channel'), + required: false, + channelTypes: [0, 5] + } + ] + }, + { + type: 'SUB_COMMAND', + name: 'history', + description: localize('staff-management-system', 'cmd-desc-history'), + options: [{ + type: 'USER', + name: 'user', + description: localize('staff-management-system', 'cmd-desc-history-user'), + required: true + }] + } + ] + }, + { + type: 'SUB_COMMAND_GROUP', + name: 'activity-check', + description: localize('staff-management-system', 'cmd-desc-ac'), + options: [ + { + type: 'SUB_COMMAND', + name: 'start', + description: localize('staff-management-system', 'cmd-desc-ac-start'), + options: [ + { + type: 'CHANNEL', + name: 'channel', + description: localize('staff-management-system', 'cmd-desc-ac-start-channel'), + required: false, + channelTypes: [0] + } + ] + }, + { + type: 'SUB_COMMAND', + name: 'view', + description: localize('staff-management-system', 'cmd-desc-ac-view') + }, + { + type: 'SUB_COMMAND', + name: 'end', + description: localize('staff-management-system', 'cmd-desc-ac-end') + } + ] + }, + { + type: 'SUB_COMMAND_GROUP', + name: 'profile', + description: localize('staff-management-system', 'cmd-desc-profile'), + options: [ + { + type: 'SUB_COMMAND', + name: 'view', + description: localize('staff-management-system', 'cmd-desc-profile-view'), + options: [{ + type: 'USER', + name: 'user', + description: localize('staff-management-system', 'cmd-desc-profile-view-user'), + required: false + }] + }, + { + type: 'SUB_COMMAND', + name: 'edit', + description: localize('staff-management-system', 'cmd-desc-profile-edit') + }, + { + type: 'SUB_COMMAND', + name: 'wipe', + description: localize('staff-management-system', 'cmd-desc-profile-wipe'), + options: [ + { + type: 'USER', + name: 'user', + description: localize('staff-management-system', 'cmd-desc-profile-wipe-user'), + required: true + } + ] + } + ] + }, + { + type: 'SUB_COMMAND_GROUP', + name: 'review', + description: localize('staff-management-system', 'cmd-desc-review'), + options: [ + { + type: 'SUB_COMMAND', + name: 'submit', + description: localize('staff-management-system', 'cmd-desc-submit'), + options: [ + { + type: 'USER', + name: 'user', + description: localize('staff-management-system', 'cmd-desc-submit-user'), + required: true + }, + { + type: 'INTEGER', + name: 'stars', + description: localize('staff-management-system', 'cmd-desc-submit-stars'), + required: true, + minValue: 1, + maxValue: 5 + }, + { + type: 'STRING', + name: 'comment', + description: localize('staff-management-system', 'cmd-desc-submit-comment'), + required: true + } + ] + }, + { + type: 'SUB_COMMAND', + name: 'history', + description: localize('staff-management-system', 'cmd-desc-history'), + options: [{ + type: 'USER', + name: 'user', + description: localize('staff-management-system', 'cmd-desc-history-user'), + required: false + }] + } + ] + } + ] +}; \ No newline at end of file diff --git a/modules/staff-management-system/commands/status.js b/modules/staff-management-system/commands/status.js new file mode 100644 index 0000000..44177d1 --- /dev/null +++ b/modules/staff-management-system/commands/status.js @@ -0,0 +1,221 @@ +const { MessageFlags } = require('discord.js'); +const { handleStatusRequest, handleStatusView, handleStatusList, handleStatusManage } = require('../staff-management'); +const { localize } = require('../../../src/functions/localize'); + +module.exports.beforeSubcommand = async function (interaction) { + if (!interaction.replied && !interaction.deferred) { + await interaction.deferReply({ + flags: MessageFlags.Ephemeral + }); + } +}; + +module.exports.subcommands = { + 'loa': { + 'request': async function (interaction) { + const duration = interaction.options.getString('duration'); + const reason = interaction.options.getString('reason'); + await handleStatusRequest(interaction.client, interaction, 'LOA', duration, reason); + }, + 'view': async function (interaction) { + const user = interaction.options.getUser('user') || interaction.user; + await handleStatusView(interaction.client, interaction, 'LOA', user); + }, + 'list': async function (interaction) { + const filter = interaction.options.getString('filter'); + await handleStatusList(interaction.client, interaction, 'LOA', filter); + }, + 'admin': async function (interaction) { + const user = interaction.options.getMember('user'); + if (!user) return interaction.editReply({ + content: localize('staff-management-system', 'err-no-mem') + }); + await handleStatusManage(interaction.client, interaction, user, 'LOA'); + } + }, + 'ra': { + 'request': async function (interaction) { + const duration = interaction.options.getString('duration'); + const reason = interaction.options.getString('reason'); + await handleStatusRequest(interaction.client, interaction, 'RA', duration, reason); + }, + 'view': async function (interaction) { + const user = interaction.options.getUser('user') || interaction.user; + await handleStatusView(interaction.client, interaction, 'RA', user); + }, + 'list': async function (interaction) { + const filter = interaction.options.getString('filter'); + await handleStatusList(interaction.client, interaction, 'RA', filter); + }, + 'admin': async function (interaction) { + const user = interaction.options.getMember('user'); + if (!user) return interaction.editReply({ + content: localize('staff-management-system', 'err-no-mem') + }); + await handleStatusManage(interaction.client, interaction, user, 'RA'); + } + } +}; + +module.exports.config = { + name: 'status', + description: localize('staff-management-system', 'cmd-desc-status'), + usage: '/status', + type: 'slash', + defaultPermission: false, + options: [ + { + type: 'SUB_COMMAND_GROUP', + name: 'loa', + description: localize('staff-management-system', 'cmd-desc-loa'), + options: [ + { + type: 'SUB_COMMAND', + name: 'request', + description: localize('staff-management-system', 'cmd-desc-loa-request'), + options: [ + { + type: 'STRING', + name: 'duration', + description: localize('staff-management-system', 'cmd-desc-loar-duration'), + required: true + }, + { + type: 'STRING', + name: 'reason', + description: localize('staff-management-system', 'cmd-desc-loar-reason'), + required: true + } + ] + }, + { + type: 'SUB_COMMAND', + name: 'view', + description: localize('staff-management-system', 'cmd-desc-loa-view'), + options: [ + { + type: 'USER', + name: 'user', + description: localize('staff-management-system', 'cmd-desc-loav-user'), + required: false + } + ] + }, + { + type: 'SUB_COMMAND', + name: 'list', + description: localize('staff-management-system', 'cmd-desc-loa-list'), + options: [{ + type: 'STRING', + name: 'filter', + description: localize('staff-management-system', 'cmd-desc-loal-filter'), + required: true, + choices: [ + { + name: 'Active', + value: 'active' + }, + { + name: 'Expired', + value: 'expired' + }, + { + name: 'All', + value: 'all' + }] + }] + }, + { + type: 'SUB_COMMAND', + name: 'admin', + description: localize('staff-management-system', 'cmd-desc-loa-admin'), + options: [ + { + type: 'USER', + name: 'user', + description: localize('staff-management-system', 'cmd-desc-loaa-user'), + required: true + } + ] + } + ] + }, + { + type: 'SUB_COMMAND_GROUP', + name: 'ra', + description: localize('staff-management-system', 'cmd-desc-ra'), + options: [ + { + type: 'SUB_COMMAND', + name: 'request', + description: localize('staff-management-system', 'cmd-desc-ra-request'), + options: [ + { + type: 'STRING', + name: 'duration', + description: localize('staff-management-system', 'cmd-desc-rar-duration'), + required: true + }, + { + type: 'STRING', + name: 'reason', + description: localize('staff-management-system', 'cmd-desc-rar-reason'), + required: true + } + ] + }, + { + type: 'SUB_COMMAND', + name: 'view', + description: localize('staff-management-system', 'cmd-desc-ra-view'), + options: [ + { + type: 'USER', + name: 'user', + description: localize('staff-management-system', 'cmd-desc-rav-user'), + required: false + }] + }, + { + type: 'SUB_COMMAND', + name: 'list', + description: localize('staff-management-system', 'cmd-desc-ra-list'), + options: [ + { + type: 'STRING', + name: 'filter', + description: localize('staff-management-system', 'cmd-desc-ral-filter'), + required: true, + choices: [ + { + name: 'Active', + value: 'active' + }, + { + name: 'Expired', + value: 'expired' + }, + { + name: 'All', + value: 'all' + } + ] + }] + }, + { + type: 'SUB_COMMAND', + name: 'admin', + description: localize('staff-management-system', 'cmd-desc-ra-admin'), + options: [ + { + type: 'USER', + name: 'user', + description: localize('staff-management-system', 'cmd-desc-raa-user'), + required: true + } + ] + } + ] + } + ] +}; \ No newline at end of file diff --git a/modules/staff-management-system/configs/activity-checks.json b/modules/staff-management-system/configs/activity-checks.json new file mode 100644 index 0000000..5e18b79 --- /dev/null +++ b/modules/staff-management-system/configs/activity-checks.json @@ -0,0 +1,316 @@ +{ + "filename": "activity-checks.json", + "humanName": { + "en": "Activity Checks" + }, + "description": { + "en": "Configure automated staff activity checks and response logging." + }, + "categories": [ + { + "id": "general", + "icon": "fas fa-clipboard-user", + "displayName": { + "en": "General Settings" + } + }, + { + "id": "exceptions", + "icon": "fa-solid fa-badge-check", + "displayName": { + "en": "Exceptions" + } + }, + { + "id": "automation", + "icon": "far fa-robot", + "displayName": { + "en": "Automation" + } + }, + { + "id": "results", + "icon": "fa-solid fa-check-to-slot", + "displayName": { + "en": "Results & Logging" + } + } + ], + "content": [ + { + "name": "enableActivityChecks", + "category": "general", + "humanName": { + "en": "Enable Activity Checks" + }, + "description": { + "en": "Allows admins to start an activity check to see who is active." + }, + "type": "boolean", + "default": { + "en": true + }, + "elementToggle": true + }, + { + "name": "targetRoles", + "category": "general", + "humanName": { + "en": "Roles to Check" + }, + "description": { + "en": "The roles required to respond to the activity check. Anyone with these roles will be expected to click the button. Leave empty to default to the General Staff Roles." + }, + "type": "array", + "content": "roleID", + "default": { + "en": [] + }, + "allowNull": true + }, + { + "name": "timeframe", + "category": "general", + "humanName": { + "en": "Check Duration (Hours)" + }, + "description": { + "en": "How long staff have to respond to the activity check (Max 168 hours / 1 week)." + }, + "type": "integer", + "minValue": 1, + "maxValue": 168, + "default": { + "en": 24 + } + }, + { + "name": "checkMessage", + "category": "general", + "humanName": { + "en": "Activity Check Embed" + }, + "description": { + "en": "The message sent when an activity check starts." + }, + "type": "string", + "allowEmbed": true, + "params": [ + { + "name": "endtime", + "description": { + "en": "The Discord timestamp when the check ends." + } + }, + { + "name": "duration", + "description": { + "en": "The configured duration in hours." + } + } + ], + "default": { + "en": { + "title": "📋 Staff Activity Check", + "description": "Please click the button below to confirm your activity before %endtime%.", + "color": "#3498db" + } + } + }, + { + "name": "sendingChannel", + "category": "general", + "humanName": { + "en": "Default Sending Channel" + }, + "description": { + "en": "The default channel where the activity check message will be posted. This can manually be overridden with the command." + }, + "type": "channelID", + "channelTypes": [ + "GUILD_TEXT" + ], + "default": { + "en": "" + }, + "allowNull": true + }, + { + "name": "exceptionsType", + "category": "exceptions", + "humanName": { + "en": "Exceptions Rule" + }, + "description": { + "en": "Who are excused from the activity checks?" + }, + "type": "select", + "content": [ + "No exceptions", + "Only LoA", + "Only RA", + "LoA and RA", + "Custom role(s)" + ], + "default": { + "en": "LoA and RA" + } + }, + { + "name": "customExceptionRoles", + "category": "exceptions", + "humanName": { + "en": "Custom Exception Roles" + }, + "description": { + "en": "Only applies if 'Custom role(s)' is selected above." + }, + "type": "array", + "content": "roleID", + "default": { + "en": [] + }, + "allowNull": true + }, + { + "name": "automatedChecks", + "category": "automation", + "humanName": { + "en": "Automated Checks" + }, + "description": { + "en": "If enabled, the bot will automatically start activity checks at configured intervals." + }, + "type": "boolean", + "default": { + "en": false + } + }, + { + "name": "automatedCheckInterval", + "category": "automation", + "humanName": { + "en": "Automated Check Interval" + }, + "description": { + "en": "On which interval to start automatic checks. Choose cronjob for full customzation." + }, + "type": "select", + "content": [ + "Weekly", + "Biweekly", + "Monthly", + "Cronjob" + ], + "default": { + "en": "Biweekly" + }, + "dependsOn": "automatedChecks" + }, + { + "name": "automatedCheckCronjob", + "category": "automation", + "humanName": { + "en": "Automated Check Cronjob" + }, + "description": { + "en": "The cronjob schedule for automatic checks. Only applies if 'Cronjob' is selected above." + }, + "type": "string", + "default": { + "en": "" + }, + "dependsOn": "automatedChecks", + "allowNull": true + }, + { + "name": "automatedCheckWeekDay", + "category": "automation", + "humanName": { + "en": "Automated Check Week Day" + }, + "description": { + "en": "The week day to start automatic checks." + }, + "type": "select", + "content": [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday" + ], + "default":{ + "en": "Monday" + }, + "dependsOn": "automatedChecks" + }, + { + "name": "automatedCheckMonthWeek", + "category": "automation", + "humanName": { + "en": "Automated Check Month Week" + }, + "description": { + "en": "The week of the month to start automatic checks. Only applies if 'Monthly' is selected above." + }, + "type": "integer", + "minValue": 1, + "maxValue": 4, + "default": { + "en": 1 + }, + "dependsOn": "automatedChecks" + }, + { + "name": "logChannel", + "category": "results", + "humanName": { + "en": "Results Channel" + }, + "description": { + "en": "Where the final results are posted. Leave empty if you want to use the general log channel." + }, + "type": "channelID", + "default": { + "en": "" + }, + "channelTypes": [ + "GUILD_TEXT" + ], + "allowNull": true + }, + { + "name": "pingResults", + "category": "results", + "humanName": { + "en": "Ping on Results" + }, + "description": { + "en": "Ping specific roles when the results are posted." + }, + "type": "boolean", + "default": { + "en": false + } + }, + { + "name": "pingRoles", + "category": "results", + "humanName": { + "en": "Roles to Ping" + }, + "description": { + "en": "The roles to ping with the results message." + }, + "type": "array", + "content": "roleID", + "default": { + "en": [] + }, + "dependsOn": "pingResults" + } + ] +} \ No newline at end of file diff --git a/modules/staff-management-system/configs/configuration.json b/modules/staff-management-system/configs/configuration.json new file mode 100644 index 0000000..600aeff --- /dev/null +++ b/modules/staff-management-system/configs/configuration.json @@ -0,0 +1,86 @@ +{ + "filename": "configuration.json", + "humanName": { + "en": "General Configuration" + }, + "description": { + "en": "Configure the main staff roles and the default log channel." + }, + "categories": [ + { + "id": "roles", + "icon": "fas fa-clipboard-user", + "displayName": { + "en": "Staff Roles" + } + }, + { + "id": "logging", + "icon": "fa-solid fa-clipboard-list", + "displayName": { + "en": "Logging" + } + } + ], + "content": [ + { + "name": "staffRoles", + "category": "roles", + "humanName": { + "en": "Staff Roles" + }, + "description": { + "en": "Roles that can use basic staff commands (Shifts, LoA Request and RA Request, reviews etc.)." + }, + "type": "array", + "content": "roleID", + "default": { + "en": [] + } + }, + { + "name": "supervisorRoles", + "category": "roles", + "humanName": { + "en": "Supervisor Roles" + }, + "description": { + "en": "Roles that can manage other staff members (Approve/Deny/Manage LoA's and RA's, Manage Shifts, promote and infract users)." + }, + "type": "array", + "content": "roleID", + "default": { + "en": [] + } + }, + { + "name": "managementRoles", + "category": "roles", + "humanName": { + "en": "Management Roles" + }, + "description": { + "en": "Roles with full access, including data deletion abilities." + }, + "type": "array", + "content": "roleID", + "default": { + "en": [] + } + }, + { + "name": "generalLogChannel", + "category": "logging", + "humanName": { + "en": "General Log Channel" + }, + "description": { + "en": "The default channel where logs happen such as status changes/request and more. This can be overridden in some features." + }, + "type": "channelID", + "default": { + "en": "" + } + } + ] +} \ No newline at end of file diff --git a/modules/staff-management-system/configs/infractions.json b/modules/staff-management-system/configs/infractions.json new file mode 100644 index 0000000..385198b --- /dev/null +++ b/modules/staff-management-system/configs/infractions.json @@ -0,0 +1,454 @@ +{ + "filename": "infractions.json", + "humanName": { + "en": "Infractions & Suspensions" + }, + "description": { + "en": "Configure how staff infractions, strikes, and suspensions are handled." + }, + "categories": [ + { + "id": "logic", + "icon": "fas fa-hammer", + "displayName": { + "en": "General Logic" + } + }, + { + "id": "suspensions", + "icon": "fa fa-bell-exclamation", + "displayName": { + "en": "Suspensions Logic" + } + }, + { + "id": "messages", + "icon": "fa fa-messages", + "displayName": { + "en": "Messages & Embeds" + } + } + ], + "content": [ + { + "name": "enableInfractions", + "category": "logic", + "humanName": { + "en": "Enable Infractions System" + }, + "description": { + "en": "Enabling this will unlock features such as issuing infractions to staff members, suspensions and more." + }, + "type": "boolean", + "elementToggle": true, + "default": { + "en": true + } + }, + { + "name": "infractionTypes", + "category": "logic", + "humanName": { + "en": "Infraction Types" + }, + "description": { + "en": "These are the types of infractions that can be issued to staff members. You can customize these to fit your infractions system." + }, + "type": "array", + "content": "string", + "default": { + "en": [ + "Warning", + "Strike", + "Demotion", + "Termination", + "Under Investigation" + ] + } + }, + { + "name": "enableSuspensions", + "category": "suspensions", + "humanName": { + "en": "Enable Suspensions System" + }, + "description": { + "en": "Suspensions temporarily strip a staff member of their roles." + }, + "type": "boolean", + "elementToggle": true, + "default": { + "en": true + } + }, + { + "name": "suspensionHierarchyRole", + "category": "suspensions", + "humanName": { + "en": "Hierarchy Base Role" + }, + "description": { + "en": "When suspending, the bot will remove all roles above and including this one. This would usually be your lowest 'Staff' role." + }, + "type": "roleID", + "allowNull": true, + "dependsOn": "enableSuspensions", + "default": { + "en": "" + } + }, + { + "name": "suspensionRole", + "category": "suspensions", + "humanName": { + "en": "Suspended Role (Optional)" + }, + "description": { + "en": "A role to assign the user while they are suspended (e.g., 'Suspended Staff')." + }, + "type": "roleID", + "allowNull": true, + "dependsOn": "enableSuspensions", + "default": { + "en": "" + } + }, + { + "name": "suspensionMessage", + "category": "suspensions", + "humanName": { + "en": "Suspension Announcement Embed" + }, + "description": { + "en": "The embed sent to the log channel when a staff member is suspended." + }, + "type": "string", + "allowEmbed": true, + "dependsOn": "enableSuspensions", + "params": [ + { + "name": "user", + "description": { + "en": "Mention of the staff member" + } + }, + { + "name": "userPfp", + "description": { + "en": "Avatar of the staff member" + }, + "isImage": true + }, + { + "name": "issuerMention", + "description": { + "en": "Mention of the manager issuing it" + } + }, + { + "name": "issuerName", + "description": { + "en": "Name of the issuer" + } + }, + { + "name": "issuerPfp", + "description": { + "en": "Avatar of the issuer" + }, + "isImage": true + }, + { + "name": "duration", + "description": { + "en": "Duration of suspension" + } + }, + { + "name": "endDate", + "description": { + "en": "Timestamp of when the suspension ends" + } + }, + { + "name": "reason", + "description": { + "en": "Reason provided" + } + }, + { + "name": "caseId", + "description": { + "en": "Database Case ID" + } + } + ], + "default": { + "en": { + "content": "%user%", + "embeds": [ + { + "author": { + "name": "Signed, %issuerName% â€ĸ Case #%caseId%", + "iconURL": "%issuerPfp%" + }, + "title": "⛔ Staff Suspension", + "description": "**Staff Member:** %user%\n**Duration:** %duration%\n**Ends:** %endDate%\n**Reason:** %reason%", + "color": "#ed4245", + "thumbnailURL": "%userPfp%" + } + ] + } + } + }, + { + "name": "infractionLogChannel", + "category": "messages", + "humanName": { + "en": "Infraction Log Channel" + }, + "description": { + "en": "Where should infractions and suspensions be announced?" + }, + "type": "channelID", + "channelTypes": [ + "GUILD_TEXT", + "GUILD_NEWS" + ], + "default": { + "en": "" + } + }, + { + "name": "infractionMessage", + "category": "messages", + "humanName": { + "en": "Infraction Announcement Embed" + }, + "description": { + "en": "The embed sent to the log channel for regular infractions." + }, + "type": "string", + "allowEmbed": true, + "params": [ + { + "name": "user", + "description": { + "en": "Mention of the staff member" + } + }, + { + "name": "userPfp", + "description": { + "en": "Avatar of the staff member" + }, + "isImage": true + }, + { + "name": "issuerMention", + "description": { + "en": "Mention of the manager issuing it" + } + }, + { + "name": "issuerName", + "description": { + "en": "Name of the issuer" + } + }, + { + "name": "issuerPfp", + "description": { + "en": "Avatar of the issuer" + }, + "isImage": true + }, + { + "name": "duration", + "description": { + "en": "Duration of suspension" + } + }, + { + "name": "endDate", + "description": { + "en": "Timestamp of when the suspension ends" + } + }, + { + "name": "reason", + "description": { + "en": "Reason provided" + } + }, + { + "name": "caseId", + "description": { + "en": "Database Case ID" + } + } + ], + "default": { + "en": { + "content": "%user%", + "embeds": [ + { + "author": { + "name": "Signed, %issuerName% â€ĸ Case #%caseId%", + "iconURL": "%issuerPfp%" + }, + "title": "âš ī¸ New %type%", + "description": "**Staff Member:** %user%\n**Action Taken:** %type%\n**Expires:** %endDate%\n**Reason:** %reason%", + "color": "#e67e22", + "thumbnailURL": "%userPfp%" + } + ] + } + } + }, + { + "name": "dmInfractedUser", + "category": "messages", + "humanName": { + "en": "DM User on Infraction?" + }, + "description": { + "en": "If enabled, the bot will DM the staff member when they receive an infraction or suspension." + }, + "type": "boolean", + "default": { + "en": true + } + }, + { + "name": "infractionDmMessage", + "category": "messages", + "humanName": { + "en": "Infraction DM Embed" + }, + "description": { + "en": "The message sent directly to the staff member." + }, + "type": "string", + "allowEmbed": true, + "dependsOn": "dmInfractedUser", + "params": [ + { + "name": "user", + "description": { + "en": "Mention of the staff member" + } + }, + { + "name": "issuerName", + "description": { + "en": "Name of the issuer" + } + }, + { + "name": "type", + "description": { + "en": "Type of infraction (e.g., Warning, Strike)" + } + }, + { + "name": "endDate", + "description": { + "en": "Timestamp of when this infraction expires" + } + }, + { + "name": "reason", + "description": { + "en": "Reason provided" + } + }, + { + "name": "caseId", + "description": { + "en": "Database Case ID" + } + } + ], + "default": { + "en": { + "embeds": [ + { + "author": { + "name": "Signed, %issuerName% â€ĸ Case #%caseId%" + }, + "title": "âš ī¸ Staff Notice: %type%", + "description": "You have received a formal **%type%** from the management team.\n\n**Reason:** %reason%\n**Expires:** %endDate%", + "color": "#e67e22" + } + ] + } + } + }, + { + "name": "suspensionDmMessage", + "category": "messages", + "humanName": { + "en": "Suspension DM Embed" + }, + "description": { + "en": "The message sent directly to the staff member when suspended." + }, + "type": "string", + "allowEmbed": true, + "dependsOn": "dmInfractedUser", + "params": [ + { + "name": "user", + "description": { + "en": "Mention of the staff member" + } + }, + { + "name": "issuerName", + "description": { + "en": "Name of the issuer" + } + }, + { + "name": "type", + "description": { + "en": "Type of infraction (e.g., Warning, Strike)" + } + }, + { + "name": "endDate", + "description": { + "en": "Timestamp of when this infraction expires" + } + }, + { + "name": "reason", + "description": { + "en": "Reason provided" + } + }, + { + "name": "caseId", + "description": { + "en": "Database Case ID" + } + } + ], + "default": { + "en": { + "embeds": [ + { + "author": { + "name": "Signed, %issuerName% â€ĸ Case #%caseId%" + }, + "title": "⛔ Staff Suspension", + "description": "Your staff privileges have been temporarily suspended.\n\n**Duration:** %duration%\n**Returns:** %endDate%\n**Reason:** %reason%\n\nDuring this time, your roles have been removed and you are expected to step away from all staff duties.", + "color": "#ed4245" + } + ] + } + } + } + ] +} \ No newline at end of file diff --git a/modules/staff-management-system/configs/profiles.json b/modules/staff-management-system/configs/profiles.json new file mode 100644 index 0000000..bbbaacf --- /dev/null +++ b/modules/staff-management-system/configs/profiles.json @@ -0,0 +1,144 @@ +{ + "filename": "profiles.json", + "humanName": { + "en": "Staff Profiles" + }, + "description": { + "en": "Configure the staff profile system (Intros, custom nicknames, and stats)." + }, + "categories": [ + { + "id": "settings", + "icon": "fa-user-tie", + "displayName": { + "en": "Profile Settings" + } + } + ], + "content": [ + { + "name": "enableProfiles", + "category": "settings", + "humanName": { + "en": "Enable Staff Profiles" + }, + "description": { + "en": "Allows staff to have a profile tracking their shifts, reviews, and a custom introduction." + }, + "type": "boolean", + "default": { + "en": true + }, + "elementToggle": true + }, + { + "name": "onlyAllowStaffProfile", + "category": "settings", + "humanName": { + "en": "Only allow staff and higher to have their own customizable profile" + }, + "description": { + "en": "If enabled, only staff members and higher will be able to set a custom profile nickname and introduction. If disabled, all members will be able to set a custom profile nickname and introduction." + }, + "type": "boolean", + "default": { + "en": true + } + }, + { + "name": "managePermission", + "category": "settings", + "humanName": { + "en": "Profile Moderation Permission" + }, + "description": { + "en": "Which group is allowed to forcibly wipe another staff member's profile?" + }, + "type": "select", + "content": [ + "Supervisor", + "Management" + ], + "default": { + "en": "Supervisor" + } + }, + { + "name": "profileEmbedMessage", + "category": "settings", + "humanName": { + "en": "Profile Embed" + }, + "description": { + "en": "Customize the embed shown when viewing a staff profile." + }, + "type": "string", + "allowEmbed": true, + "params": [ + { + "name": "user", + "description": { + "en": "The user's mention." + } + }, + { + "name": "username", + "description": { + "en": "The user's standard Discord username." + } + }, + { + "name": "nickname", + "description": { + "en": "The user's custom profile nickname (or default username if not set)." + } + }, + { + "name": "intro", + "description": { + "en": "The user's custom introduction." + } + }, + { + "name": "status", + "description": { + "en": "The user's current status (On Duty, Off Duty, LoA, etc.)." + } + }, + { + "name": "rating", + "description": { + "en": "The user's average review rating." + } + }, + { + "name": "pfp", + "description": { + "en": "The user's avatar URL." + }, + "isImage": true + } + ], + "default": { + "en": { + "title": "Staff Profile: %nickname%", + "description": "%intro%", + "color": "#2b2d31", + "thumbnail": "%pfp%", + "fields": [ + { + "name": "Status", + "value": "%status%", + "inline": true + }, + { + "name": "Average Rating", + "value": "%rating%", + "inline": true + } + ] + } + } + } + ] +} \ No newline at end of file diff --git a/modules/staff-management-system/configs/promotions.json b/modules/staff-management-system/configs/promotions.json new file mode 100644 index 0000000..ff4e233 --- /dev/null +++ b/modules/staff-management-system/configs/promotions.json @@ -0,0 +1,247 @@ +{ + "filename": "promotions.json", + "humanName": { + "en": "Promotions" + }, + "description": { + "en": "Configure how staff promotions are handled and announced." + }, + "categories": [ + { + "id": "logic", + "icon": "fas fa-gears", + "displayName": { + "en": "General logic" + } + }, + { + "id": "messages", + "icon": "fas fa-comment-dots", + "displayName": { + "en": "Announcements" + } + } + ], + "content": [ + { + "name": "enablePromotions", + "category": "logic", + "humanName": { + "en": "Enable Promotions System" + }, + "description": { + "en": "If disabled, the /staff-management promote command will not work." + }, + "type": "boolean", + "default": { + "en": true + }, + "elementToggle": true + }, + { + "name": "autoAddRole", + "category": "logic", + "humanName": { + "en": "Auto-Add New Role?" + }, + "description": { + "en": "If enabled, the bot will automatically give the user the new rank role. Note: This makes your server prone to raids by promoting someone to a role with more dangerous permissions which can be used to do malicious actions. It is recommended to keep this setting disabled." + }, + "type": "boolean", + "default": { + "en": true + } + }, + { + "name": "promotionsChannel", + "category": "messages", + "humanName": { + "en": "Promotions Channel" + }, + "description": { + "en": "The channel where promotion announcements will be sent." + }, + "type": "channelID", + "channelTypes": [ + "GUILD_TEXT", + "GUILD_ANNOUNCEMENT" + ], + "default": { + "en": "" + } + }, + { + "name": "promotionMessage", + "category": "messages", + "humanName": { + "en": "Promotion Announcement Embed" + }, + "description": { + "en": "This will be the message sent when someone is promoted." + }, + "type": "string", + "allowEmbed": true, + "params": [ + { + "name": "user", + "description": { + "en": "Pings the promoted user." + } + }, + { + "name": "newRoleName", + "description": { + "en": "The plain text name of the new role." + } + }, + { + "name": "newRoleMention", + "description": { + "en": "The pingable mention of the new role." + } + }, + { + "name": "promoterMention", + "description": { + "en": "Pings the staff member who issued the promotion." + } + }, + { + "name": "promoterName", + "description": { + "en": "The username of the staff member who issued the promotion." + } + }, + { + "name": "reason", + "description": { + "en": "The reason for the promotion." + } + }, + { + "name": "userPfp", + "description": { + "en": "The avatar URL of the promoted user." + } + }, + { + "name": "promoterPfp", + "description": { + "en": "The avatar URL of the promoter." + } + } + ], + "default": { + "en": { + "content": "%user%", + "embeds": [ + { + "author": { + "name": "Signed, %promoterName%", + "imageURL": "%promoterPfp%" + }, + "title": "🎉 New promotion!", + "description": "Congratulations, you have been promoted to **%newRoleName%**!\n\n**Promoted to:** %newRoleMention%\n**On behalf of:** %promoterMention%\n**Reason:** %reason%", + "color": "#f1c40f", + "thumbnailURL": "%userPfp%" + } + ] + } + } + }, + { + "name": "dmPromotedUser", + "category": "messages", + "humanName": { + "en": "DM Promoted User?" + }, + "description": { + "en": "If enabled, the user will receive a direct message when promoted." + }, + "type": "boolean", + "default": { + "en": false + } + }, + { + "name": "promotionDmMessage", + "category": "messages", + "humanName": { + "en": "Promotion DM Embed" + }, + "description": { + "en": "The message sent directly to the user." + }, + "type": "string", + "allowEmbed": true, + "dependsOn": "dmPromotedUser", + "params": [ + { + "name": "user", + "description": { + "en": "Pings the promoted user." + } + }, + { + "name": "newRoleName", + "description": { + "en": "The plain text name of the new role." + } + }, + { + "name": "newRoleMention", + "description": { + "en": "The pingable mention of the new role." + } + }, + { + "name": "promoterMention", + "description": { + "en": "Pings the staff member who issued the promotion." + } + }, + { + "name": "promoterName", + "description": { + "en": "The username of the staff member who issued the promotion." + } + }, + { + "name": "reason", + "description": { + "en": "The reason for the promotion." + } + }, + { + "name": "userPfp", + "description": { + "en": "The avatar URL of the promoted user." + } + }, + { + "name": "promoterPfp", + "description": { + "en": "The avatar URL of the promoter." + } + } + ], + "default": { + "en": { + "content": "%user%", + "embeds": [ + { + "author": { + "name": "Signed, %promoterName%", + "imageURL": "%promoterPfp%" + }, + "title": "🎉 New promotion!", + "description": "Congratulations, you have been promoted to **%newRoleName%**!\n\n**Promoted to:** %newRoleMention%\n**On behalf of:** %promoterMention%\n**Reason:** %reason%", + "color": "#f1c40f", + "thumbnailURL": "%userPfp%" + } + ] + } + } + } + ] +} \ No newline at end of file diff --git a/modules/staff-management-system/configs/reviews.json b/modules/staff-management-system/configs/reviews.json new file mode 100644 index 0000000..b8685eb --- /dev/null +++ b/modules/staff-management-system/configs/reviews.json @@ -0,0 +1,147 @@ +{ + "filename": "reviews.json", + "humanName": { + "en": "Staff Ratings" + }, + "description": { + "en": "Configure the staff rating system and feedback channels." + }, + "categories": [ + { + "id": "settings", + "icon": "fas fa-gears", + "displayName": { + "en": "Settings" + } + }, + { + "id": "messages", + "icon": "fa fa-messages", + "displayName": { + "en": "Notifications" + } + } + ], + "content": [ + { + "name": "enableReviews", + "category": "settings", + "humanName": { + "en": "Enable Reviews System" + }, + "description": { + "en": "Enabling this unlocks the staff review system, allowing users to submit ratings and feedback for staff members." + }, + "type": "boolean", + "default": { + "en": true + } + }, + { + "name": "reviewLogChannel", + "category": "settings", + "humanName": { + "en": "Reviews Log Channel" + }, + "description": { + "en": "Channel where new reviews are posted." + }, + "type": "channelID", + "default": { + "en": "" + } + }, + { + "name": "allowSelfRating", + "category": "settings", + "humanName": { + "en": "Allow Self-Rating?" + }, + "description": { + "en": "If enabled, staff can review themselves. This is not recommended to keep a fair ratings system." + }, + "type": "boolean", + "default": { + "en": false + } + }, + { + "name": "onlyAllowStaffReview", + "category": "settings", + "humanName": { + "en": "Only let users review staff" + }, + "description": { + "en": "If enabled, only staff members can review other staff members." + }, + "type": "boolean", + "default": { + "en": true + } + }, + { + "name": "ratingMessage", + "category": "messages", + "humanName": { + "en": "Review Message" + }, + "description": { + "en": "The message sent when a review is submitted." + }, + "type": "string", + "allowEmbed": true, + "params": [ + { + "name": "target", + "description": { + "en": "The staff member" + } + }, + { + "name": "author", + "description": { + "en": "The reviewer" + } + }, + { + "name": "stars", + "description": { + "en": "Star emoji string (⭐⭐⭐⭐⭐)" + } + }, + { + "name": "rating", + "description": { + "en": "Number (1-5)" + } + }, + { + "name": "comment", + "description": { + "en": "The review text" + } + }, + { + "name": "staff-profile-picture", + "description": { + "en": "The staff member's profile picture (URL)" + } + }, + { + "name": "reviewer-profile-picture", + "description": { + "en": "The reviewer's profile picture (URL)" + } + } + ], + "default": { + "en": { + "title": "🌟 New Staff Rating", + "description": "**Staff:** %target%\n**Rated by:** %author%\n\n**Rating:** %stars% (%rating%/5)\n**Comment:**\n%comment%", + "color": "#f1c40f", + "thumbnail": "%staff-profile-picture%" + } + } + } + ] +} \ No newline at end of file diff --git a/modules/staff-management-system/configs/shifts.json b/modules/staff-management-system/configs/shifts.json new file mode 100644 index 0000000..ee7b250 --- /dev/null +++ b/modules/staff-management-system/configs/shifts.json @@ -0,0 +1,179 @@ +{ + "filename": "shifts.json", + "humanName": { + "en": "Shift Management" + }, + "description": { + "en": "Configure shift requirements, duty roles, leaderboards, and quotas." + }, + "categories": [ + { + "id": "settings", + "icon": "fas fa-gears", + "displayName": { + "en": "Shift Settings" + } + }, + { + "id": "leaderboard", + "icon": "fas fa-ranking-stars", + "displayName": { + "en": "Leaderboard" + } + }, + { + "id": "quotas", + "icon": "fa-solid fa-check-to-slot", + "displayName": { + "en": "Quotas" + } + } + ], + "content": [ + { + "name": "enableShifts", + "category": "settings", + "humanName": { + "en": "Enable Shifts" + }, + "description": { + "en": "This unlocks the ability for staff to use a shifts system, where they can get on-duty, off-duty, take a break and see their total duty time." + }, + "type": "boolean", + "default": { + "en": true + }, + "elementToggle": true + }, + { + "name": "onDutyRole", + "category": "settings", + "humanName": { + "en": "On-Duty Role" + }, + "description": { + "en": "Role given to users when they are on-duty. This is optional, but recommended to easily identify who is on-duty." + }, + "type": "roleID", + "allowNull": true, + "default": { + "en": "" + } + }, + { + "name": "dutyTypes", + "category": "settings", + "humanName": { + "en": "Duty Types" + }, + "description": { + "en": "The types of duty a staff member can select when going on-duty." + }, + "type": "array", + "content": "string", + "default": { + "en": ["Staff"] + } + }, + { + "name": "minShiftDuration", + "category": "settings", + "humanName": { + "en": "Minimum Shift Duration (minutes)" + }, + "description": { + "en": "A minimum shift duration for a shift to count towards their duty time. Default is 0, which means all shift time counts." + }, + "type": "integer", + "default": { + "en": 0 + } + }, + { + "name": "enableLeaderboard", + "category": "leaderboard", + "humanName": { + "en": "Enable duty leaderboard" + }, + "description": { + "en": "If enabled, staff can see a leaderboard of who has the most duty time in the configured timeframe." + }, + "type": "boolean", + "default": { + "en": true + } + }, + { + "name": "leaderboardLookback", + "category": "leaderboard", + "humanName": { + "en": "Leaderboard Timeframe" + }, + "description": { + "en": "The timeframe of the duty time shown on the leaderboard." + }, + "type": "select", + "content": [ + "Weekly", + "Monthly", + "All-time" + ], + "default": { + "en": "Weekly" + }, + "dependsOn": "enableLeaderboard" + }, + { + "name": "enableQuotas", + "category": "quotas", + "humanName": { + "en": "Enable Quota System" + }, + "description": { + "en": "If enabled, you can set a custom quota of hours for staff to meet in the configured timeframe." + }, + "type": "boolean", + "default": { + "en": false + } + }, + { + "name": "quotaTimeframe", + "category": "quotas", + "humanName": { + "en": "Quota Timeframe" + }, + "description": { + "en": "The timeframe in which the quota must be met." + }, + "type": "select", + "content": [ + "Weekly", + "Monthly" + ], + "default": { + "en": "Weekly" + }, + "dependsOn": "enableQuotas" + }, + { + "name": "quotas", + "category": "quotas", + "humanName": { + "en": "Role Quotas" + }, + "description": { + "en": "Set required hours per role - the left side will be the role, and the right side is a number which is the hours for the quota. The user's highest role counts as their quota." + }, + "type": "keyed", + "content": { + "key": "roleID", + "value": "integer" + }, + "default": { + "en": {} + }, + "dependsOn": "enableQuotas" + } + ] +} \ No newline at end of file diff --git a/modules/staff-management-system/configs/status.json b/modules/staff-management-system/configs/status.json new file mode 100644 index 0000000..599cac5 --- /dev/null +++ b/modules/staff-management-system/configs/status.json @@ -0,0 +1,195 @@ +{ + "filename": "status.json", + "humanName": { + "en": "LoA & RA Status" + }, + "description": { + "en": "Configure Leave of Absence (LoA) and Reduced Activity (RA) settings." + }, + "categories": [ + { + "id": "loa", + "icon": "fas fa-door-open", + "displayName": { + "en": "LoA Settings" + } + }, + { + "id": "ra", + "icon": "fa-user-tie", + "displayName": { + "en": "RA Settings" + } + }, + { + "id": "logging", + "icon": "fa-solid fa-clipboard-list", + "displayName": { + "en": "Requests Log" + } + } + ], + "content": [ + { + "name": "enableLoa", + "category": "loa", + "humanName": { + "en": "Enable LoA System" + }, + "description": { + "en": "If enabled, staff can request a Leave of Absence (LoA)." + }, + "type": "boolean", + "default": { + "en": false + }, + "elementToggle": true + }, + { + "name": "loaRole", + "category": "loa", + "humanName": { + "en": "LoA Role" + }, + "description": { + "en": "Role given to users when they are on a Leave of Absence. This is optional, but recommended to easily identify who is on LoA." + }, + "type": "roleID", + "allowNull": true, + "default": { + "en": "" + } + }, + { + "name": "loaMaxDays", + "category": "loa", + "humanName": { + "en": "Maximum LoA Duration (days)" + }, + "description": { + "en": "The maximum duration for a Leave of Absence in days. This limits how long staff can request to be on LoA for." + }, + "type": "integer", + "default": { + "en": 60 + } + }, + { + "name": "requireLoaApproval", + "category": "loa", + "humanName": { + "en": "Require Approval for LoA?" + }, + "description": { + "en": "If enabled, LoA requests will require approval from staff who have supervisor permissions or higher." + }, + "type": "boolean", + "default": { + "en": true + } + }, + { + "name": "enableRa", + "category": "ra", + "humanName": { + "en": "Enable RA System" + }, + "description": { + "en": "If enabled, staff can request Reduced Activity (RA) status for when they are still working but at a reduced load." + }, + "type": "boolean", + "default": { + "en": false + }, + "elementToggle": true + }, + { + "name": "raRole", + "category": "ra", + "humanName": { + "en": "RA Role" + }, + "description": { + "en": "Role given to users when they are on Reduced Activity. This is optional, but recommended to easily identify who is on RA." + }, + "type": "roleID", + "allowNull": true, + "default": { + "en": "" + } + }, + { + "name": "raMaxDays", + "category": "ra", + "humanName": { + "en": "Maximum RA Duration (days)" + }, + "description": { + "en": "The maximum duration for RA in days. This limits how long staff can request to be on RA for." + }, + "type": "integer", + "default": { + "en": 30 + } + }, + { + "name": "requireRaApproval", + "category": "ra", + "humanName": { + "en": "Require Approval for RA?" + }, + "description": { + "en": "If enabled, RA requests will require approval from staff who have supervisor permissions or higher." + }, + "type": "boolean", + "default": { + "en": true + } + }, + { + "name": "statusLogChannel", + "category": "logging", + "humanName": { + "en": "Status Request Channel" + }, + "description": { + "en": "Channel where requests are sent for approval." + }, + "type": "channelID", + "allowNull": true, + "default": { + "en": "" + } + }, + { + "name": "logStatusChanges", + "category": "logging", + "humanName": { + "en": "Log status changes" + }, + "description": { + "en": "If enabled, any changes in staff status (going on/off LoA or RA) will be logged in the configured channel." + }, + "type": "boolean", + "default": { + "en": true + } + }, + { + "name": "statusChangeLogChannel", + "category": "logging", + "humanName": { + "en": "Status Change Log Channel" + }, + "description": { + "en": "Channel where status changes are logged. By default this uses your main log channel, but you can set a separate channel here." + }, + "type": "channelID", + "allowNull": true, + "default": { + "en": "" + }, + "dependsOn": "logStatusChanges" + } + ] +} \ No newline at end of file diff --git a/modules/staff-management-system/events/botReady.js b/modules/staff-management-system/events/botReady.js new file mode 100644 index 0000000..615cd05 --- /dev/null +++ b/modules/staff-management-system/events/botReady.js @@ -0,0 +1,81 @@ +const schedule = require('node-schedule'); +const { localize } = require('../../../src/functions/localize'); +const { Op } = require('sequelize'); +const { scheduleStatusExpiry } = require('../staff-management'); + +module.exports.run = async (client) => { + try { + const LoaRequest = client.models['staff-management-system']['LoaRequest']; + const activeRequests = await LoaRequest.findAll({ + where: { status: 'APPROVED' } + }); + + let loaded = 0; + for (const req of activeRequests) { + scheduleStatusExpiry(client, req); + loaded++; + } + } catch (e) { + client.logger.error(localize('staff-management-system', 'log-sched-fail', { error: e.message })); + } + + const jobName = 'staff-management-checks'; + const existingJob = schedule.scheduledJobs[jobName]; + if (existingJob) existingJob.cancel(); + + const job = schedule.scheduleJob(jobName, '0 * * * *', async function() { + if (!client.botReadyAt) return; + + const guild = client.guilds.cache.get(client.guildID); + if (!guild) return; + + await checkExpiredSuspensions(client, guild); + }); + if (!client.intervals) client.intervals = []; + client.intervals.push(job); +}; + +async function checkExpiredSuspensions(client, guild) { + const Infraction = client.models['staff-management-system']['Infraction']; + const StaffProfile = client.models['staff-management-system']['StaffProfile']; + const config = client.configurations['staff-management-system']['infractions']; + const activeSuspensions = await Infraction.findAll({ + where: { type: 'Suspension', active: true } + }); + + for (const susp of activeSuspensions) { + const startDate = new Date(susp.createdAt); + const expireDate = new Date(startDate.getTime() + (susp.durationDays * 24 * 60 * 60 * 1000)); + + if (new Date() >= expireDate) { + const member = await guild.members.fetch(susp.userId).catch(() => null); + const profile = await StaffProfile.findByPk(susp.userId); + + if (member && profile && profile.suspendedRoles) { + try { + const rolesToAdd = JSON.parse(profile.suspendedRoles); + if (Array.isArray(rolesToAdd)) { + await member.roles.add(rolesToAdd).catch(e => client.logger.warn(`Failed to restore roles for ${member.user.tag}: ${e.message}`)); + } + + if (config.suspensionRole) { + await member.roles.remove(config.suspensionRole).catch(() => {}); + } + + await susp.update({ active: false }); + await profile.update({ + isSuspended: false, + suspendedRoles: null + }); + + client.logger.info(localize('staff-management-system', 'log-susp-end', { tag: member.user.tag })); + + } catch (e) { + client.logger.error(localize('staff-management-system', 'log-susp-err', { error: e.message })); + } + } else { + await susp.update({ active: false }); + } + } + } +} \ No newline at end of file diff --git a/modules/staff-management-system/events/guildMemberRemove.js b/modules/staff-management-system/events/guildMemberRemove.js new file mode 100644 index 0000000..38e0f6f --- /dev/null +++ b/modules/staff-management-system/events/guildMemberRemove.js @@ -0,0 +1,38 @@ +const { Op } = require('sequelize'); +const { localize } = require('../../../src/functions/localize'); + +module.exports.run = async (client, member) => { + if (member.guild.id !== client.guildID) return; + + const StaffShift = client.models['staff-management-system']['StaffShift']; + const StaffProfile = client.models['staff-management-system']['StaffProfile']; + + try { + const openShift = await StaffShift.findOne({ + where: { + userId: member.id, + endTime: null + } + }); + + if (openShift) { + const now = new Date(); + const duration = Math.floor((now - openShift.startTime) / 1000); + + await openShift.update({ + endTime: now, + duration: duration + }); + + client.logger.info(localize('staff-management-system', 'log-shift-leave', { tag: member.user.tag })); + } + + await StaffProfile.update( + { onDuty: false }, + { where: { userId: member.id } } + ); + + } catch (e) { + client.logger.error(localize('staff-management-system', 'log-leave-err', { error: e.message })); + } +}; \ No newline at end of file diff --git a/modules/staff-management-system/events/interactionCreate.js b/modules/staff-management-system/events/interactionCreate.js new file mode 100644 index 0000000..2e6a12a --- /dev/null +++ b/modules/staff-management-system/events/interactionCreate.js @@ -0,0 +1,529 @@ +const { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags, EmbedBuilder } = require('discord.js'); +const { + generateReviewHistoryResponse, + handleStatusEnd, + scheduleStatusExpiry, + handleStatusEndSubmit, + handleStatusExtend, + handleStatusExtendSubmit, + handleStatusHistPage, + sendStatusDm, + generatePromotionHistoryResponse, + generateInfractionHistoryResponse, + generateUserPanel, + generatePanelInfractions, + generatePanelPromotions, + generatePanelReviews, + generatePanelStatus, + generatePanelActivity, + generatePanelShifts, + generatePanelDeletion, + executeDataDeletion, + generatePanelSubpage, + logStatusChange +} = require('../staff-management'); +const { localize } = require('../../../src/functions/localize'); +const dutyHandlers = require('../commands/duty.js').buttonHandlers; +const configuration = require('../configuration.json'); + +module.exports.run = async (client, interaction) => { + if (!client.botReadyAt) return; + if (!interaction.guild || interaction.guild.id !== client.guildID) return; + if (!interaction.customId || (!interaction.customId.startsWith('staff-mgmt_') && !interaction.customId.startsWith('duty-mgmt_'))) return; + + try { + const parts = interaction.customId.split('_'); + const action = parts[1]; + + // ----- Duty manage handlers ----- + if (interaction.customId.startsWith('duty-mgmt_')) { + const dutyAction = parts[1]; + + if (interaction.isStringSelectMenu() && dutyAction === 'dropdown') { + await interaction.deferUpdate(); + return await dutyHandlers.handleDutyDropdown(client, interaction, parts[2], interaction.values[0]); + } + + if (['start', 'break', 'end', 'hist', 'lb', 'admin-forceend', 'admin-voidactive'].includes(dutyAction)) { + await interaction.deferUpdate(); + } + + if (dutyAction === 'start') return await dutyHandlers.handleDutyStartButton(client, interaction); + if (dutyAction === 'break') return await dutyHandlers.handleDutyBreakButton(client, interaction); + if (dutyAction === 'end') return await dutyHandlers.handleDutyEndButton(client, interaction); + if (dutyAction === 'hist') return await dutyHandlers.handleDutyHistPageButton(client, interaction); + if (dutyAction === 'lb') return await dutyHandlers.handleDutyLbPageButton(client, interaction); + if (dutyAction === 'admin-forceend') return await dutyHandlers.handleDutyAdminForceEnd(client, interaction); + if (dutyAction === 'admin-voidactive') return await dutyHandlers.handleDutyAdminVoidActive(client, interaction); + if (dutyAction === 'admin-voidall') return await dutyHandlers.handleDutyAdminVoidAll(client, interaction); + if (dutyAction === 'admin-voidall-submit') return await dutyHandlers.handleDutyAdminVoidAllSubmit(client, interaction); + if (dutyAction === 'admin-addtime') return await dutyHandlers.handleDutyAdminAddTimeButton(client, interaction); + if (dutyAction === 'admin-addtime-submit') return await dutyHandlers.handleDutyAdminAddTimeSubmit(client, interaction); + return; + } + + // ----- Review history pagination ----- + if (action === 'rev-page') { + const targetUser = await client.users.fetch(parts[2]).catch(() => null); + if (!targetUser) return interaction.reply({ + content: localize('staff-management-system', 'err-gen-no-user'), + flags: MessageFlags.Ephemeral + }); + + const payload = await generateReviewHistoryResponse(client, targetUser, parseInt(parts[3])); + if (payload.content) return interaction.reply(payload); + return interaction.update(payload); + } + + // ----- LOA/RA handlers ----- + const loaActions = ['loa-end', 'loa-end-submit', 'loa-extend', 'loa-extend-submit', 'loa-hist']; + const raActions = ['ra-end', 'ra-end-submit', 'ra-extend', 'ra-extend-submit', 'ra-hist']; + + if (loaActions.includes(action) || raActions.includes(action)) { + const type = action.startsWith('loa-') ? 'LOA' : 'RA'; + const base = action.replace(/^(loa|ra)-/, ''); + + if (base === 'end') return handleStatusEnd(interaction, type); + if (base === 'end-submit') return handleStatusEndSubmit(interaction, type); + if (base === 'extend') return handleStatusExtend(interaction, type); + if (base === 'extend-submit') return handleStatusExtendSubmit(interaction, type); + if (base === 'hist') return handleStatusHistPage(interaction, type); + } + + // ----- Promotion history pagination ----- + if (action === 'prom-hist') { + const targetUser = await client.users.fetch(parts[2]).catch(() => null); + if (!targetUser) return interaction.reply({ + content: localize('staff-management-system', 'err-gen-no-user'), + flags: MessageFlags.Ephemeral + }); + + const payload = await generatePromotionHistoryResponse(client, targetUser, parseInt(parts[3], 10)); + if (payload.content) return interaction.reply(payload); + return interaction.update(payload); + } + + // ----- Infraction history pagination ----- + if (action === 'inf-hist') { + const targetUser = await client.users.fetch(parts[2]).catch(() => null); + if (!targetUser) return interaction.reply({ + content: localize('staff-management-system', 'err-gen-no-user'), + flags: MessageFlags.Ephemeral + }); + const payload = await generateInfractionHistoryResponse(client, targetUser, parseInt(parts[3], 10)); + if (payload.content) return interaction.reply(payload); + return interaction.update(payload); + } + + // ----- User panel dropdown ----- + if (interaction.customId.startsWith('staff-mgmt_panel-menu_')) { + const targetId = interaction.customId.split('_')[2]; + const targetUser = await client.users.fetch(targetId).catch(() => null); + if (!targetUser) return interaction.reply({ + content: localize('staff-management-system', 'err-gen-no-user'), + flags: MessageFlags.Ephemeral + }); + + const selection = interaction.values[0]; + let payload; + if (selection === 'overview') payload = await generateUserPanel(client, targetUser); + else if (selection === 'infractions') payload = await generatePanelInfractions(client, targetUser, 1); + else if (selection === 'promotions') payload = await generatePanelPromotions(client, targetUser, 1); + else if (selection === 'reviews') payload = await generatePanelReviews(client, targetUser, 1); + else if (selection === 'status') payload = await generatePanelStatus(client, targetUser, 1); + else if (selection === 'activity') payload = await generatePanelActivity(client, targetUser, 1); + else if (selection === 'shifts') payload = await generatePanelShifts(client, targetUser); + else if (selection === 'deletion') payload = await generatePanelDeletion(client, targetUser); + + return interaction.update(payload); + } + + // ----- User panel deletion dropdown ----- + if (interaction.customId.startsWith('staff-mgmt_delete-menu_')) { + const targetId = interaction.customId.split('_')[2]; + const selection = interaction.values[0]; + + if (selection === 'back') { + const targetUser = await client.users.fetch(targetId).catch(() => null); + if (!targetUser) return interaction.reply({ + content: localize('staff-management-system', 'err-gen-no-user'), + flags: MessageFlags.Ephemeral + }); + + const payload = await generateUserPanel(client, targetUser); + return interaction.update(payload); + } + + const confirmPhrase = localize('staff-management-system', 'del-conf-phrase'); + const modal = new ModalBuilder() + .setCustomId(`staff-mgmt_del-confirm_${targetId}_${selection}`) + .setTitle(localize('staff-management-system', 'mod-del-title')); + modal.addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('confirm') + .setLabel(localize('staff-management-system', 'mod-del-lbl')) + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder(confirmPhrase) + .setRequired(true) + ) + ); + return interaction.showModal(modal); + } + + // ----- Data deletion modal submission ----- + if (interaction.isModalSubmit() && interaction.customId.startsWith('staff-mgmt_del-confirm_')) { + const managementRoles = Array.isArray(configuration.managementRoles) + ? configuration.managementRoles + : []; + const memberRoles = interaction.member && interaction.member.roles && interaction.member.roles.cache + ? interaction.member.roles.cache + : null; + const hasManagementRole = memberRoles + ? managementRoles.some((roleId) => memberRoles.has(roleId)) + : false; + if (!hasManagementRole) { + return interaction.reply({ + content: localize('staff-management-system', 'del-no-perm'), + flags: MessageFlags.Ephemeral + }); + } + + const parts = interaction.customId.split('_'); + const targetId = parts[2]; + const selection = parts.slice(3).join('_'); + + const confirmPhrase = localize('staff-management-system', 'del-conf-phrase'); + + if (interaction.fields.getTextInputValue('confirm').trim() !== confirmPhrase) { + return interaction.reply({ + content: localize('staff-management-system', 'err-conf-fail'), + flags: MessageFlags.Ephemeral + }); + } + + if (selection === 'del_all') { + const embed = new EmbedBuilder() + .setTitle(localize('staff-management-system', 'del-all-title')) + .setDescription(localize('staff-management-system', 'del-all-desc')) + .setColor('DarkRed'); + + const row = new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId(`staff-mgmt_del-all-confirm_${targetId}`) + .setLabel(localize('staff-management-system', 'btn-conf-del')) + .setStyle(ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId(`staff-mgmt_del-all-cancel_${targetId}`) + .setLabel(localize('staff-management-system', 'btn-cancel')) + .setStyle(ButtonStyle.Secondary) + ); + + await interaction.reply({ + embeds: [embed.toJSON()], + components: [row.toJSON()], + flags: MessageFlags.Ephemeral + }); + + const reply = await interaction.fetchReply(); + const collector = reply.createMessageComponentCollector({ time: 30000 }); + + collector.on('collect', async (btnInt) => { + const managementRoles = Array.isArray(configuration.managementRoles) ? configuration.managementRoles : []; + const memberRoles = btnInt.member && btnInt.member.roles && btnInt.member.roles.cache + ? btnInt.member.roles.cache + : null; + const hasManagementRole = memberRoles + ? managementRoles.some((roleId) => memberRoles.has(roleId)) + : false; + if (!hasManagementRole) { + return btnInt.reply({ + content: localize('staff-management-system', 'del-no-perm'), + flags: MessageFlags.Ephemeral + }); + } + + if (btnInt.customId.includes('cancel')) { + await btnInt.update({ + content: localize('staff-management-system', 'succ-del-canc'), + embeds: [], + components: [] + }); + collector.stop('cancelled'); + } else if (btnInt.customId.includes('confirm')) { + await executeDataDeletion(client, targetId, 'del_all'); + + client.logger.info(localize('staff-management-system', 'log-del-all', { + target: targetId, + admin: btnInt.user.id + })); + + const targetUser = await client.users.fetch(targetId).catch(() => null); + if (targetUser) { + const payload = await generateUserPanel(client, targetUser); + await interaction.message.edit(payload).catch(()=>{}); + } + + await btnInt.update({ + content: localize('staff-management-system', 'succ-del-all'), + embeds: [], + components: [] + }); + collector.stop('confirmed'); + } + }); + + collector.on('end', (reason) => { + if (reason === 'time') { + interaction.editReply({ + content: localize('staff-management-system', 'err-del-time'), + embeds: [], + components: [] + }).catch(()=>{}); + } + }); + return; + } + + await executeDataDeletion(client, targetId, selection); + client.logger.info(localize('staff-management-system', 'log-del-type', { + type: selection, + target: targetId, + admin: interaction.user.id + })); + const targetUser = await client.users.fetch(targetId).catch(() => null); + if (targetUser) { + const payload = await generateUserPanel(client, targetUser); + await interaction.message.edit(payload).catch(()=>{}); + } + + return interaction.reply({ + content: localize('staff-management-system', 'succ-del-tgt'), + flags: MessageFlags.Ephemeral + }); + } + + // ----- User panel buttons ----- + if (interaction.customId.startsWith('staff-mgmt_panel-')) { + const parts = interaction.customId.split('_'); + const targetId = parts[2]; + const page = parseInt(parts[3], 10); + + const targetUser = await client.users.fetch(targetId).catch(() => null); + if (!targetUser) return interaction.reply({ + content: localize('staff-management-system', 'err-gen-no-user'), + flags: MessageFlags.Ephemeral + }); + + const typeMap = { + 'inf': 'infractions', + 'prom': 'promotions', + 'rev': 'reviews', + 'stat': 'status', + 'act': 'activity' + }; + const fullType = typeMap[parts[1].split('-')[1]]; + + if (fullType) { + const payload = await generatePanelSubpage(client, targetUser, fullType, page); + if (payload) return interaction.update(payload); + } + } + + // ----- Status buttons ----- + const LoARequest = client.models['staff-management-system']['LoaRequest']; + const StaffProfile = client.models['staff-management-system']['StaffProfile']; + const config = client.configurations['staff-management-system']['configuration']; + const statusConfig = client.configurations['staff-management-system']['status']; + + if (action === 'approve' || action === 'deny') { + const isSupervisor = interaction.member.roles.cache.some(r => config.supervisorRoles.includes(r.id)) || + interaction.member.roles.cache.some(r => config.managementRoles.includes(r.id)) || + interaction.member.permissions.has('Administrator'); + + if (!isSupervisor) return interaction.reply({ + content: localize('staff-management-system', 'err-gen-no-perm'), + flags: MessageFlags.Ephemeral + }); + + const request = await LoARequest.findByPk(parts[2]); + if (!request) return interaction.reply({ + content: localize('staff-management-system', 'err-no-req'), + flags: MessageFlags.Ephemeral + }); + if (request.status !== 'PENDING') return interaction.reply({ + content: localize('staff-management-system', 'err-req-hndl', { status: request.status }), + flags: MessageFlags.Ephemeral + }); + + if (action === 'deny') { + const modal = new ModalBuilder() + .setCustomId(`staff-mgmt_loa-deny_${parts[2]}`) + .setTitle(localize('staff-management-system', 'mod-deny-req')); + modal.addComponents( + new ActionRowBuilder() + .addComponents( + new TextInputBuilder() + .setCustomId('reason') + .setLabel(localize('staff-management-system', 'general-rsn')) + .setStyle(TextInputStyle.Paragraph) + .setRequired(true) + ) + ); + return interaction.showModal(modal); + } + + if (action === 'approve') { + await request.update({ + status: 'APPROVED', + approverId: interaction.user.id + }); + await StaffProfile.upsert({ + userId: request.userId, + activityStatus: request.type + }); + scheduleStatusExpiry(client, request); + + const member = await interaction.guild.members.fetch(request.userId).catch(() => null); + if (member) { + const roleId = request.type === 'LOA' + ? statusConfig.loaRole + : statusConfig.raRole; + if (roleId) await member.roles.add(roleId).catch(() => {}); + await sendStatusDm(member.user, request.type, 'approved', { + approver: interaction.user.tag, + endDate: request.endDate + }); + } + + await logStatusChange(client, request.type, 'start', { + userId: request.userId, + startDate: request.startDate, + endDate: request.endDate, + reason: request.reason, + approverId: interaction.user.id + }); + + const embed = EmbedBuilder + .from(interaction.message.embeds[0]) + .setColor('Green') + .addFields({ + name: localize('staff-management-system', 'general-stat'), + value: localize('staff-management-system', 'req-appr-by', { + user: interaction.user.tag + }) + }); + return interaction.update({ + embeds: [embed.toJSON()], + components: [] + }); + } + } + + // ----- Deny modal submission ----- + if (interaction.isModalSubmit() && action === 'loa-deny') { + const reason = interaction.fields.getTextInputValue('reason'); + const request = await LoARequest.findByPk(parts[2]); + + if (request) { + await request.update({ + status: 'DENIED', + approverId: interaction.user.id, + rejectionReason: reason + }); + const member = await interaction.guild.members.fetch(request.userId).catch(() => null); + if (member) await sendStatusDm(member.user, request.type, 'denied', { + denier: interaction.user.tag, + reason + }); + + const embed = EmbedBuilder + .from(interaction.message.embeds[0]) + .setColor('Red') + .addFields({ + name: localize('staff-management-system', 'general-stat'), + value: localize('staff-management-system', 'req-deny-by', { + user: interaction.user.tag + }) + }, { + name: localize('staff-management-system', 'general-rsn'), + value: reason + }); + return interaction.update({ + embeds: [embed.toJSON()], + components: [] + }); + } + } + + // ----- Profile edit submission ----- + if (interaction.isModalSubmit() && action === 'profile-edit') { + const nickname = interaction.fields.getTextInputValue('nickname'); + const intro = interaction.fields.getTextInputValue('intro'); + + const Profile = client.models['staff-management-system']['StaffProfile']; + await Profile.upsert({ + userId: interaction.user.id, + customNickname: nickname || null, + customIntro: intro || null + }); + return interaction.reply({ + content: localize('staff-management-system', 'succ-prof-upd'), + flags: MessageFlags.Ephemeral + }); + } + + // ----- Activity checks button ----- + if (action === 'ac-respond') { + const ActivityCheck = client.models['staff-management-system']['ActivityCheck']; + const activeCheck = await ActivityCheck.findOne({ + where: { + status: 'ACTIVE', + messageId: interaction.message.id + } + }); + + if (!activeCheck) return interaction.reply({ + content: localize('staff-management-system', 'err-ac-alr-end'), + flags: MessageFlags.Ephemeral + }); + + const targetRoles = JSON.parse(activeCheck.targetRoles || '[]'); + const hasRole = targetRoles.length === 0 || interaction.member.roles.cache.some(r => targetRoles.includes(r.id)); + if (!hasRole) return interaction.reply({ + content: localize('staff-management-system', 'err-ac-notreq'), + flags: MessageFlags.Ephemeral + }); + + let responded = JSON.parse(activeCheck.respondedUsers || '[]'); + if (responded.includes(interaction.user.id)) return interaction.reply({ + content: localize('staff-management-system', 'info-ac-alr-conf'), + flags: MessageFlags.Ephemeral + }); + + responded.push(interaction.user.id); + await activeCheck.update({ + respondedUsers: JSON.stringify(responded) + }); + return interaction.reply({ + content: localize('staff-management-system', 'succ-ac-log'), + flags: MessageFlags.Ephemeral + }); + } + + } catch (e) { + client.logger.error(localize('staff-management-system', 'log-int-error', { error: e.stack })); + if (!interaction.replied && !interaction.deferred) { + try { await interaction.reply({ + content: localize('staff-management-system', 'err-internal'), + flags: MessageFlags.Ephemeral + }); } catch (err) {} + } else { + try { await interaction.followUp({ + content: localize('staff-management-system', 'err-internal'), + flags: MessageFlags.Ephemeral + }); } catch (err) {} + } + } +}; \ No newline at end of file diff --git a/modules/staff-management-system/models/ActivityCheck.js b/modules/staff-management-system/models/ActivityCheck.js new file mode 100644 index 0000000..5d0dace --- /dev/null +++ b/modules/staff-management-system/models/ActivityCheck.js @@ -0,0 +1,46 @@ +const { DataTypes, Model } = require('sequelize'); + +module.exports = class StaffManagementActivityCheck extends Model { + static init(sequelize) { + return super.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + messageId: { + type: DataTypes.STRING, + allowNull: false + }, + channelId: { + type: DataTypes.STRING, + allowNull: false + }, + endTime: { + type: DataTypes.DATE, + allowNull: false + }, + targetRoles: { + type: DataTypes.TEXT, + allowNull: false + }, + respondedUsers: { + type: DataTypes.TEXT, + defaultValue: '[]' + }, + status: { + type: DataTypes.STRING, + defaultValue: 'ACTIVE' + } + }, { + tableName: 'staff_management_activity_checks', + timestamps: true, + sequelize + }); + } +}; + +module.exports.config = { + name: 'ActivityCheck', + module: 'staff-management-system' +}; \ No newline at end of file diff --git a/modules/staff-management-system/models/Infraction.js b/modules/staff-management-system/models/Infraction.js new file mode 100644 index 0000000..2822e9b --- /dev/null +++ b/modules/staff-management-system/models/Infraction.js @@ -0,0 +1,54 @@ +const { DataTypes, Model } = require('sequelize'); + +module.exports = class StaffManagementInfraction extends Model { + static init(sequelize) { + return super.init({ + caseId: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true + }, + userId: { + type: DataTypes.STRING, + allowNull: false + }, + issuerId: { + type: DataTypes.STRING, + allowNull: false + }, + type: { + type: DataTypes.STRING, + allowNull: false + }, + reason: { + type: DataTypes.TEXT, + allowNull: true + }, + durationDays: { + type: DataTypes.INTEGER, + allowNull: true + }, + active: { + type: DataTypes.BOOLEAN, + defaultValue: true + }, + messageUrl: { + type: DataTypes.STRING, + allowNull: true + }, + expiresAt: { + type: DataTypes.DATE, + allowNull: true + } + }, { + tableName: 'staff_management_infractions', + timestamps: true, + sequelize + }); + } +}; + +module.exports.config = { + name: 'Infraction', + module: 'staff-management-system' +}; \ No newline at end of file diff --git a/modules/staff-management-system/models/LoaRequest.js b/modules/staff-management-system/models/LoaRequest.js new file mode 100644 index 0000000..83f7128 --- /dev/null +++ b/modules/staff-management-system/models/LoaRequest.js @@ -0,0 +1,54 @@ +const { DataTypes, Model } = require('sequelize'); + +module.exports = class StaffManagementLoaRequest extends Model { + static init(sequelize) { + return super.init({ + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true + }, + userId: { + type: DataTypes.STRING, + allowNull: false + }, + type: { + type: DataTypes.STRING, + allowNull: false + }, + reason: { + type: DataTypes.TEXT, + allowNull: false + }, + startDate: { + type: DataTypes.DATE, + allowNull: false + }, + endDate: { + type: DataTypes.DATE, + allowNull: false + }, + status: { + type: DataTypes.STRING, + defaultValue: "PENDING" + }, + approverId: { + type: DataTypes.STRING, + allowNull: true + }, + rejectionReason: { + type: DataTypes.TEXT, + allowNull: true + } + }, { + tableName: 'staff_management_loa_requests', + timestamps: true, + sequelize + }); + } +}; + +module.exports.config = { + name: 'LoaRequest', + module: 'staff-management-system' +}; \ No newline at end of file diff --git a/modules/staff-management-system/models/Promotion.js b/modules/staff-management-system/models/Promotion.js new file mode 100644 index 0000000..491dbe4 --- /dev/null +++ b/modules/staff-management-system/models/Promotion.js @@ -0,0 +1,42 @@ +const { DataTypes, Model } = require('sequelize'); + +module.exports = class StaffManagementPromotion extends Model { + static init(sequelize) { + return super.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + userId: { + type: DataTypes.STRING, + allowNull: false + }, + issuerId: { + type: DataTypes.STRING, + allowNull: false + }, + newRole: { + type: DataTypes.STRING, + allowNull: false + }, + reason: { + type: DataTypes.TEXT, + allowNull: true + }, + messageUrl: { + type: DataTypes.STRING, + allowNull: true + } + }, { + tableName: 'staff_management_promotions', + timestamps: true, + sequelize + }); + } +}; + +module.exports.config = { + name: 'Promotion', + module: 'staff-management-system' +}; \ No newline at end of file diff --git a/modules/staff-management-system/models/StaffProfile.js b/modules/staff-management-system/models/StaffProfile.js new file mode 100644 index 0000000..0f66976 --- /dev/null +++ b/modules/staff-management-system/models/StaffProfile.js @@ -0,0 +1,63 @@ +const { DataTypes, Model } = require('sequelize'); + +module.exports = class StaffManagementProfile extends Model { + static init(sequelize) { + return super.init({ + userId: { + type: DataTypes.STRING, + primaryKey: true, + allowNull: false + }, + points: { + type: DataTypes.INTEGER, + defaultValue: 0, + allowNull: false + }, + onDuty: { + type: DataTypes.BOOLEAN, + defaultValue: false + }, + lastClockIn: { + type: DataTypes.DATE, + allowNull: true + }, + activityStatus: { + type: DataTypes.STRING, + defaultValue: 'ACTIVE' + }, + isSuspended: { + type: DataTypes.BOOLEAN, + defaultValue: false + }, + suspendedRoles: { + type: DataTypes.TEXT, + allowNull: true + }, + customNickname: { + type: DataTypes.STRING, + allowNull: true + }, + customIntro: { + type: DataTypes.STRING(1024), + allowNull: true + }, + onBreak: { + type: DataTypes.BOOLEAN, + defaultValue: false + }, + breakStartTime: { + type: DataTypes.DATE, + allowNull: true + } + }, { + tableName: 'staff_management_profiles', + timestamps: true, + sequelize + }); + } +}; + +module.exports.config = { + name: 'StaffProfile', + module: 'staff-management-system' +}; \ No newline at end of file diff --git a/modules/staff-management-system/models/StaffReview.js b/modules/staff-management-system/models/StaffReview.js new file mode 100644 index 0000000..1c2d379 --- /dev/null +++ b/modules/staff-management-system/models/StaffReview.js @@ -0,0 +1,43 @@ +const { DataTypes, Model } = require('sequelize'); + +module.exports = class StaffManagementReview extends Model { + static init(sequelize) { + return super.init({ + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true + }, + targetId: { + type: DataTypes.STRING, + allowNull: false + }, + authorId: { + type: DataTypes.STRING, + allowNull: false + }, + stars: { + type: DataTypes.INTEGER, + allowNull: false, + validate: { min: 1, max: 5 } + }, + comment: { + type: DataTypes.TEXT, + allowNull: true + }, + messageUrl: { + type: DataTypes.STRING, + allowNull: true + } + }, { + tableName: 'staff_management_reviews', + timestamps: true, + sequelize + }); + } +}; + +module.exports.config = { + name: 'StaffReview', + module: 'staff-management-system' +}; \ No newline at end of file diff --git a/modules/staff-management-system/models/StaffShift.js b/modules/staff-management-system/models/StaffShift.js new file mode 100644 index 0000000..bc5789b --- /dev/null +++ b/modules/staff-management-system/models/StaffShift.js @@ -0,0 +1,37 @@ +const { DataTypes, Model } = require('sequelize'); + +module.exports = class StaffManagementShift extends Model { + static init(sequelize) { + return super.init({ + userId: { + type: DataTypes.STRING, + allowNull: false + }, + startTime: { + type: DataTypes.DATE, + allowNull: false + }, + endTime: { + type: DataTypes.DATE, + allowNull: true + }, + duration: { + type: DataTypes.INTEGER, + allowNull: true + }, + type: { + type: DataTypes.STRING, + defaultValue: "General" + } + }, { + tableName: 'staff_management_shifts', + timestamps: true, + sequelize + }); + } +}; + +module.exports.config = { + name: 'StaffShift', + module: 'staff-management-system' +}; \ No newline at end of file diff --git a/modules/staff-management-system/module.json b/modules/staff-management-system/module.json new file mode 100644 index 0000000..19ebc86 --- /dev/null +++ b/modules/staff-management-system/module.json @@ -0,0 +1,33 @@ +{ + "name": "staff-management-system", + "author": { + "scnxOrgID": "148", + "name": "Kevin", + "link": "https://github.com/Kevinking500" + }, + "openSourceURL": "https://github.com/Kevinking500/CustomDCBot/tree/main/modules/staff-management-system", + "commands-dir": "/commands", + "events-dir": "/events", + "models-dir": "/models", + "config-example-files": [ + "configs/configuration.json", + "configs/infractions.json", + "configs/promotions.json", + "configs/reviews.json", + "configs/shifts.json", + "configs/status.json", + "configs/profiles.json", + "configs/activity-checks.json" + ], + "tags": [ + "moderation" + ], + "humanReadableName": { + "en": "Staff Management System", + "de": "Mitarbeiter-Management-System" + }, + "description": { + "en": "A powerful, highly customizable staff management system designed to track activity, moderate personnel, and maintain detailed staff records seamlessly.", + "de": "Ein leistungsstarkes, hochgradig anpassbares Mitarbeiter-Management-System, das entwickelt wurde, um die Aktivität zu verfolgen, das Personal zu moderieren und detaillierte Mitarbeiterakten nahtlos zu pflegen." + } +} \ No newline at end of file diff --git a/modules/staff-management-system/staff-management.js b/modules/staff-management-system/staff-management.js new file mode 100644 index 0000000..49ae01a --- /dev/null +++ b/modules/staff-management-system/staff-management.js @@ -0,0 +1,2290 @@ +/** + * Logic for the Staff Management module + * @module staff-management + * @author itskevinnn + */ +const { ModalBuilder, TextInputBuilder, TextInputStyle, EmbedBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } = require('discord.js'); +const { Op } = require('sequelize'); +const schedule = require('node-schedule'); +const { embedTypeV2, formatDate } = require('../../src/functions/helpers'); +const { localize } = require('../../src/functions/localize'); + +// --- Local helpers --- +const getConfig = (client, file) => client.configurations['staff-management-system'][file]; +const getSafeChannelId = (val) => Array.isArray(val) && val.length > 0 // Helper to get safe channel ID from config +? val[0] +: (typeof val === 'string' + ? val + : null +); +const parseDurationToDays = (input) => { + if (!input) return null; + const match = input.toString().match(/^(\d+)([dDwWmM])?$/); + if (!match) return null; + const value = parseInt(match[1], 10); + const unit = match[2]?.toLowerCase() || 'd'; + return unit === 'm' + ? value * 30 + : (unit === 'w' + ? value * 7 + : value + ); +}; + +const applyFooter = (client, embed) => { + embed.setFooter({ text: client.strings.footer, iconURL: client.strings.footerImgUrl }); + if (!client.strings.disableFooterTimestamp) embed.setTimestamp(); + return embed; +}; + +const buildPaginationRow = (backId, countId, nextId, page, totalPages) => { + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(backId) + .setLabel(localize('helpers', 'back')) + .setStyle(ButtonStyle.Primary) + .setDisabled(page <= 1), + new ButtonBuilder() + .setCustomId(countId) + .setLabel(`${page}/${totalPages}`) + .setStyle(ButtonStyle.Secondary) + .setDisabled(true), + new ButtonBuilder() + .setCustomId(nextId) + .setLabel(localize('helpers', 'next')) + .setStyle(ButtonStyle.Primary) + .setDisabled(page >= totalPages) + ); +}; + +function formatDuration(seconds) { + if (!seconds || seconds <= 0) return localize('staff-management-system', 'time-zero'); + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + const parts = []; + if (h > 0) parts.push(`${h} ${localize('staff-management-system', h !== 1 + ? 'time-hours' + : 'time-hour' + )}`); + if (m > 0) parts.push(`${m} ${localize('staff-management-system', m !== 1 + ? 'time-mins' + : 'time-min' + )}`); + if (s > 0) parts.push(`${s} ${localize('staff-management-system', s !== 1 + ? 'time-secs' + : 'time-sec' + )}`); + return parts.join(', ') || localize('staff-management-system', 'time-zero'); +} + +// ---------- Status DM's and logging ---------- + +async function sendStatusDm(user, type, dmType, data = {}) { + const label = type === 'LOA' + ? 'LoA' + : 'RA'; + const viewCmd = type === 'LOA' + ? '`/loa view`' + : '`/ra view`'; + const endFmt = data.endDate + ? `` + : ''; + + // These messages use the locales key to be easily used later + const messages = { + approved: { + title: 'dm-appr-title', + color: 'Green', + desc: 'dm-appr-desc', + params: { label, approver: data.approver, endFmt, viewCmd } + }, + denied: { + title: 'dm-deny-title', + color: 'Red', + desc: 'dm-deny-desc', + params: { label, denier: data.denier, reason: data.reason } + }, + extended: { + title: 'dm-ext-title', + color: 'Yellow', + desc: 'dm-ext-desc', + params: { label, extender: data.extender, days: data.days, endFmt, reason: data.reason, viewCmd } + }, + ended_early: { + title: 'dm-early-title', + color: 'Red', + desc: 'dm-early-desc', + params: { label, ender: data.ender, reason: data.reason } + }, + ended: { + title: 'dm-end-title', + color: 'Black', + desc: 'dm-end-desc', + params: { label } + } + }; + + const msg = messages[dmType]; + if (!msg) return; + + const embed = new EmbedBuilder() + .setTitle(localize('staff-management-system', msg.title, msg.params)) + .setDescription(localize('staff-management-system', msg.desc, msg.params)) + .setColor(msg.color); + applyFooter(user.client, embed); + + try { + await user.send({ + embeds: [embed.toJSON()] + }); + } catch (e) { + user.client.logger.error( + localize('staff-management-system', 'log-stat-dm-error', { + e: e.message, + u: user.tag + }) + ); +} +} + +async function logStatusChange(client, type, action, data) { + const statusConfig = getConfig(client, 'status'); + if (!statusConfig?.logStatusChanges) return; + + const channelId = getSafeChannelId(statusConfig.statusChangeLogChannel) || getSafeChannelId(getConfig(client, 'configuration')?.generalLogChannel); + if (!channelId) return; + + const guild = client.guilds.cache.get(client.guildID); + if (!guild) return; + const channel = await guild.channels.fetch(channelId).catch(() => null); + if (!channel) return; + + const label = type === 'LOA' + ? 'LoA' + : 'RA'; + const targetUserObj = data.targetUser || await client.users.fetch(data.userId).catch(() => null); + const mention = targetUserObj + ? targetUserObj.toString() + : `<@${data.userId}>`; + const username = targetUserObj + ? targetUserObj.username + : data.userId; + + const embed = new EmbedBuilder() + .setThumbnail(targetUserObj + ?.displayAvatarURL({ dynamic: true }) || null); + + if (action === 'start') { + embed.setTitle(localize('staff-management-system', 'log-start-title', { label, username })) + .setColor('Green') + .setDescription(localize('staff-management-system', 'log-start-desc', + { label, mention, apprText: data.approverId + ? ` ${localize('staff-management-system', 'label-appr-by')}: <@${data.approverId}>.` + : '' + })) + .addFields({ + name: localize('staff-management-system', 'log-info-hdr', { label }), + value: `**${localize('staff-management-system', 'general-start')}:** \n**${localize('staff-management-system', 'general-end')}:** \n**${localize('staff-management-system', 'general-rsn')}:** ${data.reason || localize('staff-management-system', 'none-provided')}` + }); + + } else if (action === 'end') { + embed.setTitle(localize('staff-management-system', 'log-end-title', { label, username })) + .setColor('Red') + .setDescription(localize('staff-management-system', 'log-end-desc', { label, mention })) + .addFields({ + name: localize('staff-management-system', 'log-info-hdr', { label }), + value: `**${localize('staff-management-system', 'general-started')}:** \n**${localize('staff-management-system', 'general-ended')}:** \n**${localize('staff-management-system', 'general-rsn')}:** ${data.reason || localize('staff-management-system', 'none-provided')}` + }); + + } else if (action === 'adjusted') { + embed.setTitle(localize('staff-management-system', 'log-adj-title', { label, username })) + .setColor('Yellow') + .setDescription(localize('staff-management-system', 'log-adj-desc', { label, mention, executor: data.executorId })) + .addFields({ + name: localize('staff-management-system', 'log-changes'), + value: data.changesText + }); + } + + applyFooter(client, embed); + try { + await channel.send({ + embeds: [embed.toJSON()] + }); + } catch (e) { + client.logger.error( + localize('staff-management-system', 'log-status-adj-error', { + e: e.message + }) + ); + } +} + +// ---------- Infractions ---------- +async function issueInfraction(client, interaction, targetMember, type, reason, expiryInput) { + const config = getConfig(client, 'infractions'); + if (!config?.enableInfractions) return interaction.reply({ + content: localize('staff-management-system', 'err-feat-disabled', { feature: 'Infractions' }), + flags: MessageFlags.Ephemeral + }); + if (type.toLowerCase() === 'suspension') { + return interaction.reply({ + content: localize('staff-management-system', 'err-use-susp'), + flags: MessageFlags.Ephemeral + }); + } + + let expiresAt = null; + if (expiryInput) { + const days = parseDurationToDays(expiryInput); + if (!days) return interaction.reply({ + content: localize('staff-management-system', 'err-inv-dur'), + flags: MessageFlags.Ephemeral + }); + expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000); + } + + const record = await client.models['staff-management-system']['Infraction'].create({ + userId: targetMember.id, + issuerId: interaction.user.id, + type, reason, expiresAt, + active: true + }); + + const placeholders = { + '%user%': targetMember.user.toString(), + '%userPfp%': targetMember.user.displayAvatarURL({ dynamic: true, format: 'png', size: 1024 }) || '', + '%issuerMention%': interaction.user.toString(), + '%issuerName%': interaction.user.username, + '%issuerPfp%': interaction.user.displayAvatarURL({ dynamic: true, format: 'png', size: 1024 }) || '', + '%type%': type, + '%reason%': reason, + '%caseId%': record.caseId.toString(), + '%endDate%': expiresAt + ? `` + : localize('staff-management-system', 'label-never') + }; + + const channelId = getSafeChannelId(config.infractionLogChannel); + if (channelId) { + const channel = await interaction.guild.channels.fetch(channelId).catch(() => null); + if (channel) { + let template = config.infractionMessage; + if (typeof template === 'string') { + try { template = JSON.parse(template); } + catch (e) {} + } + else if (typeof template === 'object') { + template = JSON.parse(JSON.stringify(template)); + } + + if (template && template.embeds && !template._schema) template._schema = 'v3'; + let msgOpts = await embedTypeV2(template, placeholders); + if (msgOpts?.content?.trim() === '') delete msgOpts.content; + + if (msgOpts?.embeds?.length > 0) { + const parsedEmbed = EmbedBuilder.from(msgOpts.embeds[0]); + applyFooter(client, parsedEmbed); + msgOpts.embeds[0] = parsedEmbed.toJSON(); + } + + const sentMsg = await channel.send(msgOpts).catch(()=>{}); + if (sentMsg) await record.update({ messageUrl: sentMsg.url }); + } + } + + if (config.dmInfractedUser) { + let dmTemplate = config.infractionDmMessage; + if (typeof dmTemplate === 'string') { + try { dmTemplate = JSON.parse(dmTemplate); } + catch (e) {} + } + else if (typeof dmTemplate === 'object') { + dmTemplate = JSON.parse(JSON.stringify(dmTemplate)); + } + + if (dmTemplate && dmTemplate.embeds && !dmTemplate._schema) dmTemplate._schema = 'v3'; + let dmOpts = await embedTypeV2(dmTemplate, placeholders); + if (dmOpts?.content?.trim() === '') delete dmOpts.content; + if (dmOpts && (dmOpts.content || dmOpts.embeds?.length > 0)) + await targetMember.send(dmOpts).catch(()=>{}); + } + + await interaction.reply({ + content: localize('staff-management-system', 'succ-infract', { + type, caseId: record.caseId, user: targetMember.user.tag + }), + flags: MessageFlags.Ephemeral + }); +} + +// ---------- Suspensions ---------- +async function issueSuspension(client, interaction, targetMember, durationInput, reason) { + const config = getConfig(client, 'infractions'); + if (!config?.enableInfractions) + return interaction.reply({ + content: localize('staff-management-system', 'err-feat-disabled', { + feature: 'Infractions' + }), + flags: MessageFlags.Ephemeral + }); + + if (!config?.enableSuspensions) + return interaction.reply({ + content: localize('staff-management-system', 'err-feat-disabled', { + feature: 'Suspensions' + }), + flags: MessageFlags.Ephemeral + }); + + const durationDays = parseDurationToDays(durationInput); + if (!durationDays) + return interaction.reply({ + content: localize('staff-management-system', 'err-inv-dur'), + flags: MessageFlags.Ephemeral + }); + + const expiresAt = new Date(Date.now() + durationDays * 24 * 60 * 60 * 1000); + const durationString = `${durationDays} ${localize('staff-management-system', 'label-days')}`; + + const hierarchyRole = interaction.guild.roles.cache.get(config.suspensionHierarchyRole); + if (hierarchyRole) { + const rolesToRemove = targetMember.roles.cache.filter(r => r.position >= hierarchyRole.position && r.id !== interaction.guild.id && !r.managed).map(r => r.id); + if (rolesToRemove.length) { + await targetMember.roles.remove(rolesToRemove).catch(() => {}); + await client.models['staff-management-system']['StaffProfile'].upsert({ + userId: targetMember.id, + isSuspended: true, + suspendedRoles: JSON.stringify(rolesToRemove) + }); + } + } + if (config.suspensionRole) await targetMember.roles.add(config.suspensionRole).catch(() => {}); + + const record = await client.models['staff-management-system']['Infraction'].create({ + userId: targetMember.id, + issuerId: interaction.user.id, + type: 'Suspension', + reason, durationDays, expiresAt, + active: true + }); + + const placeholders = { + '%user%': targetMember.user.toString(), + '%userPfp%': targetMember.user.displayAvatarURL({ dynamic: true, format: 'png', size: 1024 }) || '', + '%issuerMention%': interaction.user.toString(), + '%issuerName%': interaction.user.username, + '%issuerPfp%': interaction.user.displayAvatarURL({ dynamic: true, format: 'png', size: 1024 }) || '', + '%duration%': durationString, + '%reason%': reason, + '%caseId%': record.caseId.toString(), + '%endDate%': `` + }; + + const channelId = getSafeChannelId(config.infractionLogChannel); + if (channelId) { + const channel = await interaction.guild.channels.fetch(channelId).catch(() => null); + if (channel) { + let template = config.suspensionMessage; + if (typeof template === 'string') { + try { + template = JSON.parse(template); + } + catch (e) {} + } + else if (typeof template === 'object') { + template = JSON.parse(JSON.stringify(template)); + } + + if (template && template.embeds && !template._schema) template._schema = 'v3'; + let msgOpts = await embedTypeV2(template, placeholders); + if (msgOpts?.content?.trim() === '') delete msgOpts.content; + + if (msgOpts?.embeds?.length > 0) { + const parsedEmbed = EmbedBuilder.from(msgOpts.embeds[0]); + applyFooter(client, parsedEmbed); + msgOpts.embeds[0] = parsedEmbed.toJSON(); + } + + const sentMsg = await channel.send(msgOpts).catch(()=>{}); + if (sentMsg) await record.update({ messageUrl: sentMsg.url }); + } + } + + if (config.dmInfractedUser) { + let dmTemplate = config.suspensionDmMessage; + if (typeof dmTemplate === 'string') { + try { + dmTemplate = JSON.parse(dmTemplate); + } + catch (e) {} + } + else if (typeof dmTemplate === 'object') { + dmTemplate = JSON.parse(JSON.stringify(dmTemplate)); + } + + if (dmTemplate && dmTemplate.embeds && !dmTemplate._schema) dmTemplate._schema = 'v3'; + let dmOpts = await embedTypeV2(dmTemplate, placeholders); + if (dmOpts?.content?.trim() === '') delete dmOpts.content; + if (dmOpts && (dmOpts.content || dmOpts.embeds?.length > 0)) await targetMember.send(dmOpts).catch(()=>{}); + } + + await interaction.reply({ + content: localize('staff-management-system', 'succ-susp', { + caseId: record.caseId, + user: targetMember.user.tag, + duration: durationString + }), + flags: MessageFlags.Ephemeral + }); +} + +// ----- Infractions voiding ----- +async function voidInfraction(client, interaction, caseId) { + const config = getConfig(client, 'infractions'); + if (!config?.enableInfractions) return interaction.reply({ + content: localize('staff-management-system', 'err-feat-disabled', { + feature: 'Infractions' + }), + flags: MessageFlags.Ephemeral + }); + + const generalConfig = getConfig(client, 'configuration'); + const canManage = interaction.member.roles.cache.some(r => [...(generalConfig.supervisorRoles || []), ...(generalConfig.managementRoles || [])].includes(r.id)) || interaction.member.permissions.has('Administrator'); + if (!canManage) return interaction.reply({ + content: localize('staff-management-system', 'err-gen-no-perm'), + flags: MessageFlags.Ephemeral + }); + + const record = await client.models['staff-management-system']['Infraction'].findByPk(caseId); + if (!record) return interaction.reply({ + content: localize('staff-management-system', 'err-no-case', { caseId }), + flags: MessageFlags.Ephemeral + }); + if (!record.active) return interaction.reply({ + content: localize('staff-management-system', 'err-case-inact', { caseId }), + flags: MessageFlags.Ephemeral + }); + + await record.update({ active: false }); + + if (record.type.toLowerCase() === 'suspension') { + const Profile = client.models['staff-management-system']['StaffProfile']; + const profile = await Profile.findOne({ + where: { userId: record.userId } + }); + const member = await interaction.guild.members.fetch(record.userId).catch(() => null); + + if (member && profile && profile.isSuspended) { + try { + const rolesToRestore = JSON.parse(profile.suspendedRoles || '[]'); + if (rolesToRestore.length > 0) await member.roles.add(rolesToRestore); + if (config.suspensionRole) await member.roles.remove(config.suspensionRole); + await profile.update({ isSuspended: false, suspendedRoles: '[]' }); + } catch (e) { + return interaction.reply({ + content: localize('staff-management-system', 'succ-void-fail', { caseId }), + flags: MessageFlags.Ephemeral + }); + } + } + } + await interaction.reply({ + content: localize('staff-management-system', 'succ-void', { caseId }), + flags: MessageFlags.Ephemeral + }); +} + +// ----- Generates infractions history embed ----- +async function generateInfractionHistoryResponse(client, targetUser, page = 1) { + const limit = 5; + const offset = (page - 1) * limit; + const { count, rows } = await client.models['staff-management-system']['Infraction'].findAndCountAll({ + where: { userId: targetUser.id }, + order: [['createdAt', 'DESC']], + limit, offset + }); + + if (count === 0) + return { + content: localize('staff-management-system', 'info-clean-rec', { + username: targetUser.username + }), + flags: MessageFlags.Ephemeral + }; + + const totalPages = Math.ceil(count / limit) || 1; + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'rec-title', { username: targetUser.username })) + .setThumbnail(targetUser.displayAvatarURL({ dynamic: true })) + .setColor('Red') + ); + + const desc = rows.map(r => { + const link = r.messageUrl + ? ` â€ĸ [Jump](${r.messageUrl})` + : ''; + const statusIcon = r.active + ? '🔴' + : localize('staff-management-system', 'icon-voided'); + const expiry = r.expiresAt + ? `\n**${localize('staff-management-system', 'label-exp')}:** ` + : ''; + + return `**${statusIcon} ${localize('staff-management-system', 'label-case')} #${r.caseId} - ${r.type}**\n**${localize('staff-management-system', 'label-date')}:** \n**${localize('staff-management-system', 'label-iss')}:** <@${r.issuerId}>\n**${localize('staff-management-system', 'general-rsn')}:** ${r.reason}${expiry}${link}`; + }).join('\n\n'); + + embed.setDescription(desc); + embed.addFields({ name: '\u200b', value: localize('staff-management-system', 'page-count', { + page, + total: totalPages + }) }); + + const row = buildPaginationRow( + `staff-mgmt_inf-hist_${targetUser.id}_${page - 1}`, + 'inf_hist_count', + `staff-mgmt_inf-hist_${targetUser.id}_${page + 1}`, + page, totalPages + ); + + return { embeds: [embed.toJSON()], components: [row.toJSON()] }; +} + +// ----- Gets infraction history ----- +async function getInfractionHistory(client, interaction, targetUser) { + const response = await generateInfractionHistoryResponse(client, targetUser, 1); + if (response.content && response.content.startsWith('â„šī¸')) return interaction.reply(response); + await interaction.reply({ + ...response, + flags: MessageFlags.Ephemeral + }); +} + +// ---------- Promotions ---------- +async function promoteUser(client, interaction, targetMember, newRole, reason) { + const config = getConfig(client, 'promotions'); + if (!config?.enablePromotions) return interaction.reply({ + content: localize('staff-management-system', 'err-feat-disabled', { feature: 'Promotions' }), + flags: MessageFlags.Ephemeral + }); + + const finalReason = reason && reason.trim() !== '' + ? reason + : localize('staff-management-system', 'none-provided'); + const channelOverride = interaction.options.getChannel('channel'); + + if (config.autoAddRole) { + if (interaction.guild.members.me.roles.highest.position <= newRole.position) { + return interaction.reply({ + content: localize('staff-management-system', 'err-role-hier'), + flags: MessageFlags.Ephemeral + }); + } + try { + await targetMember.roles.add(newRole); + } + catch (e) { + return interaction.reply({ + content: localize('staff-management-system', 'err-add-role', { e: e.message }), + flags: MessageFlags.Ephemeral + }); } + } + + const record = await client.models['staff-management-system']['Promotion'].create({ + userId: targetMember.id, + issuerId: interaction.user.id, + newRole: newRole.id, + reason: finalReason + }); + + const placeholders = { + '%user%': targetMember.user.toString(), + '%newRoleName%': newRole.name, + '%newRoleMention%': newRole.toString(), + '%promoterMention%': interaction.user.toString(), + '%promoterName%': interaction.user.username, + '%reason%': finalReason, + '%userPfp%': targetMember.user.displayAvatarURL({ dynamic: true, format: 'png', size: 1024 }) || '', + '%promoterPfp%': interaction.user.displayAvatarURL({ dynamic: true, format: 'png', size: 1024 }) || '' + }; + + const targetChannelId = channelOverride + ? channelOverride.id + : getSafeChannelId(config.promotionsChannel); + + if (targetChannelId) { + const channel = await interaction.guild.channels.fetch(targetChannelId).catch(() => null); + if (channel) { + let embedTemplate = config.promotionMessage; + if (typeof embedTemplate === 'string') { + try { + embedTemplate = JSON.parse(embedTemplate); + } + catch (e) {} } + + else if (typeof embedTemplate === 'object') { + embedTemplate = JSON.parse(JSON.stringify(embedTemplate)); + } + + if (embedTemplate && embedTemplate.embeds && !embedTemplate._schema) embedTemplate._schema = 'v3'; + let msgOpts = await embedTypeV2(embedTemplate, placeholders); + if (msgOpts?.content?.trim() === '') delete msgOpts.content; + + if (msgOpts.embeds && msgOpts.embeds.length > 0) { + const parsedEmbed = EmbedBuilder.from(msgOpts.embeds[0]); + applyFooter(client, parsedEmbed); + msgOpts.embeds[0] = parsedEmbed.toJSON(); + } + + const sentMessage = await channel + .send(msgOpts) + .catch(e => { + client.logger.error(localize('staff-management-system', 'log-promo-msg-error', { + e: e.message, + })); + return null; + }); + + if (sentMessage) await record.update({ messageUrl: sentMessage.url }); + } + } + + if (config.dmPromotedUser && config.promotionDmMessage) { + try { + let dmTemplate = config.promotionDmMessage; + if (typeof dmTemplate === 'string') { + try { + dmTemplate = JSON.parse(dmTemplate); + } catch (e) {} } + else if (typeof dmTemplate === 'object') { + dmTemplate = JSON.parse(JSON.stringify(dmTemplate)); + } + + if (dmTemplate && dmTemplate.embeds && !dmTemplate._schema) dmTemplate._schema = 'v3'; + let dmOpts = await embedTypeV2(dmTemplate, placeholders); + if (dmOpts?.content?.trim() === '') delete dmOpts.content; + + if (dmOpts && (dmOpts.content || (dmOpts.embeds && dmOpts.embeds.length > 0))) { + await targetMember.send(dmOpts).catch(()=>{}); + } + } catch (e) {} + } + + await interaction.reply({ + content: localize('staff-management-system', 'succ-promo', { + user: targetMember.user.tag, + role: newRole.name + }), + flags: MessageFlags.Ephemeral + }); +} + +// ----- Generates promotion history & embed ----- +async function generatePromotionHistoryResponse(client, targetUser, page = 1) { + const Promotion = client.models['staff-management-system']['Promotion']; + const limit = 5; + const offset = (page - 1) * limit; + + const { count, rows } = await Promotion.findAndCountAll({ + where: { + userId: targetUser.id + }, + order: [['createdAt', 'DESC']], + limit, + offset + }); + if (count === 0) return { + content: localize('staff-management-system', 'info-no-promo', { username: targetUser.username }), + flags: MessageFlags.Ephemeral + }; + + const totalPages = Math.ceil(count / limit) || 1; + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'prom-hist-title', { username: targetUser.username })) + .setThumbnail(targetUser.displayAvatarURL({ dynamic: true })) + .setColor('Gold') + ); + + const desc = rows.map((r, i) => { + const link = r.messageUrl ? ` â€ĸ [Jump](${r.messageUrl})` : ''; + return `**${offset + i + 1}. **\n**${localize('staff-management-system', 'label-role')}:** <@&${r.newRole}>\n**${localize('staff-management-system', 'label-prom-by')}:** <@${r.issuerId}>\n**${localize('staff-management-system', 'general-rsn')}:** ${r.reason}${link}`; + }).join('\n\n'); + + embed.setDescription(desc); + embed.addFields({ name: '\u200b', value: localize('staff-management-system', 'page-count', { page, total: totalPages }) }); + + const row = buildPaginationRow( + `staff-mgmt_prom-hist_${targetUser.id}_${page - 1}`, + 'prom_hist_count', + `staff-mgmt_prom-hist_${targetUser.id}_${page + 1}`, + page, totalPages + ); + + return { + embeds: [embed.toJSON()], + components: [row.toJSON()] + }; +} + +async function getPromotionHistory(client, interaction, targetUser) { + const response = await generatePromotionHistoryResponse(client, targetUser, 1); + if (response.content && response.content.startsWith('â„šī¸')) return interaction.reply(response); + await interaction.reply({ ...response, flags: MessageFlags.Ephemeral }); +} + +// ---------- User Panel ---------- +async function generatePanelSubpage(client, targetUser, type, page) { + if (type === 'infractions') return await generatePanelInfractions(client, targetUser, page); + if (type === 'promotions') return await generatePanelPromotions(client, targetUser, page); + if (type === 'reviews') return await generatePanelReviews(client, targetUser, page); + if (type === 'status') return await generatePanelStatus(client, targetUser, page); + if (type === 'activity') return await generatePanelActivity(client, targetUser, page); + return null; +} + +// Overview page +async function generateUserPanel(client, targetUser) { + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'panel-title', { + username: targetUser.username + })) + .setDescription(localize('staff-management-system', 'panel-desc', { + mention: targetUser.toString(), + id: targetUser.id + })) + .setThumbnail(targetUser.displayAvatarURL({ dynamic: true })) + .setColor('Blurple') + ); + + const menu = new StringSelectMenuBuilder() + .setCustomId(`staff-mgmt_panel-menu_${targetUser.id}`) + .setPlaceholder(localize('staff-management-system', 'panel-ph')) + .addOptions( + new StringSelectMenuOptionBuilder() + .setLabel(localize('staff-management-system', 'opt-over')) + .setValue('overview') + .setEmoji('🏠'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('staff-management-system', 'opt-act')) + .setValue('activity') + .setEmoji('📋'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('staff-management-system', 'opt-inf')) + .setValue('infractions') + .setEmoji('âš ī¸'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('staff-management-system', 'opt-prom')) + .setValue('promotions') + .setEmoji('🎉'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('staff-management-system', 'opt-rev')) + .setValue('reviews') + .setEmoji('⭐'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('staff-management-system', 'opt-shi')) + .setValue('shifts') + .setEmoji('âąī¸'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('staff-management-system', 'opt-sta')) + .setValue('status') + .setEmoji('🌙'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('staff-management-system', 'opt-del')) + .setValue('deletion') + .setEmoji('đŸ—‘ī¸') + ); + + const row = new ActionRowBuilder().addComponents(menu); + return { + embeds: [embed.toJSON()], + components: [row.toJSON()] + }; +} + +// Infractions page +async function generatePanelInfractions(client, targetUser, page = 1) { + const Infraction = client.models['staff-management-system']['Infraction']; + const allInfractions = await Infraction.findAll({ + where: { userId: targetUser.id } + }); + const count = allInfractions.length; + + let totalPages = 1; + if (count > 3) totalPages = 1 + Math.ceil((count - 3) / 5); + + const limit = page === 1 ? 3 : 5; + const offset = page === 1 ? 0 : 3 + ((page - 2) * 5); + + const typeCounts = {}; + allInfractions.forEach(inf => { typeCounts[inf.type] = (typeCounts[inf.type] || 0) + 1; }); + const typeStrings = Object.entries(typeCounts).map(([type, qty]) => `${type}: **${qty}**`).join('\n'); + + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'p-inf-title', { username: targetUser.username })) + .setColor('Red') + .setThumbnail(targetUser.displayAvatarURL({ dynamic: true })) + ); + + let desc = localize('staff-management-system', 'p-inf-desc', { + count: count, types: typeStrings || localize('staff-management-system', 'info-none') + }); + + const rows = await Infraction.findAll({ + where: { userId: targetUser.id }, + order: [['createdAt', 'DESC']], + limit, + offset + }); + + if (rows.length === 0) { + desc += localize('staff-management-system', 'p-no-hist'); + } else { + desc += rows.map(r => { + const statusIcon = r.active ? '🔴' : localize('staff-management-system', 'icon-voided'); + const expiry = r.expiresAt ? `\n**${localize('staff-management-system', 'label-exp')}:** ` : ''; + return `**${statusIcon} ${localize('staff-management-system', 'label-case')} #${r.caseId} - ${r.type}**\n**${localize('staff-management-system', 'label-date')}:** \n**${localize('staff-management-system', 'general-rsn')}:** ${r.reason}${expiry}`; + }).join('\n\n'); + } + + embed.setDescription(desc); + embed.addFields({ name: '\u200b', value: localize('staff-management-system', 'page-count', { page, total: totalPages }) }); + + const menu = ActionRowBuilder.from((await generateUserPanel(client, targetUser)).components[0]); + menu.components[0].options.find(opt => opt.data.value === 'infractions').data.default = true; + + const paginationRow = buildPaginationRow( + `staff-mgmt_panel-inf_${targetUser.id}_${page - 1}`, + 'panel_inf_count', + `staff-mgmt_panel-inf_${targetUser.id}_${page + 1}`, + page, totalPages + ); + + return { + embeds: [embed.toJSON()], + components: [menu.toJSON(), paginationRow.toJSON()] + }; +} + +// Promotions page +async function generatePanelPromotions(client, targetUser, page = 1) { + const Promotion = client.models['staff-management-system']['Promotion']; + const count = await Promotion.count({ + where: { userId: targetUser.id } + }); + + let totalPages = 1; + if (count > 3) totalPages = 1 + Math.ceil((count - 3) / 5); + + const limit = page === 1 + ? 3 + : 5; + const offset = page === 1 + ? 0 + : 3 + ((page - 2) * 5); + + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'p-prom-title', { + username: targetUser.username + })) + .setColor('Gold') + .setThumbnail(targetUser.displayAvatarURL({ dynamic: true })) + ); + + let desc = localize('staff-management-system', 'p-prom-desc', { count: count }); + const rows = await Promotion.findAll({ + where: { userId: targetUser.id }, + order: [['createdAt', 'DESC']], + limit, + offset + }); + + if (rows.length === 0) { + desc += localize('staff-management-system', 'p-no-hist'); + } else { + desc += rows.map(r => `**${localize('staff-management-system', 'label-role')}:** <@&${r.newRole}>\n**${localize('staff-management-system', 'label-prom-by')}:** <@${r.issuerId}>\n**${localize('staff-management-system', 'label-date')}:** \n**${localize('staff-management-system', 'general-rsn')}:** ${r.reason}`).join('\n\n'); + } + + embed.setDescription(desc); + embed.addFields({ name: '\u200b', value: localize('staff-management-system', 'page-count', { page, total: totalPages }) }); + + const menu = ActionRowBuilder.from((await generateUserPanel(client, targetUser)).components[0]); + menu.components[0].options.find(opt => opt.data.value === 'promotions').data.default = true; + + const paginationRow = buildPaginationRow( + `staff-mgmt_panel-prom_${targetUser.id}_${page - 1}`, + 'panel_prom_count', + `staff-mgmt_panel-prom_${targetUser.id}_${page + 1}`, + page, totalPages + ); + + return { + embeds: [embed.toJSON()], + components: [menu.toJSON(), paginationRow.toJSON()] + }; +} + +// Reviews page +async function generatePanelReviews(client, targetUser, page = 1) { + const Review = client.models['staff-management-system']['StaffReview']; + const allReviews = await Review.findAll({ + where: { targetId: targetUser.id } + }); + const count = allReviews.length; + + let totalPages = 1; + if (count > 3) totalPages = 1 + Math.ceil((count - 3) / 5); + + const limit = page === 1 ? 3 : 5; + const offset = page === 1 ? 0 : 3 + ((page - 2) * 5); + + const avg = count + ? (allReviews.reduce((a, b) => a + b.stars, 0) / count).toFixed(1) + : 0; + + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'p-rev-title', { + username: targetUser.username + })) + .setColor('Gold') + .setThumbnail(targetUser.displayAvatarURL({ dynamic: true })) + ); + + let desc = localize('staff-management-system', 'p-rev-desc', { count: count, avg: avg }); + + const rows = await Review.findAll({ + where: { targetId: targetUser.id }, + order: [['createdAt', 'DESC']], + limit, + offset + }); + if (rows.length === 0) desc += localize('staff-management-system', 'p-no-hist'); + else desc += rows.map(r => `**${"⭐".repeat(r.stars)}** ${localize('staff-management-system', 'label-by')} <@${r.authorId}>\n"${r.comment}"`).join('\n\n'); + + embed.setDescription(desc); + embed.addFields({ + name: '\u200b', + value: localize('staff-management-system', 'page-count', { + page, total: totalPages + }) + }); + + const menu = ActionRowBuilder.from((await generateUserPanel(client, targetUser)).components[0]); + menu.components[0].options.find(opt => opt.data.value === 'reviews').data.default = true; + + const paginationRow = buildPaginationRow( + `staff-mgmt_panel-rev_${targetUser.id}_${page - 1}`, + 'panel_rev_count', + `staff-mgmt_panel-rev_${targetUser.id}_${page + 1}`, + page, totalPages + ); + + return { + embeds: [embed.toJSON()], + components: [menu.toJSON(), paginationRow.toJSON()] + }; +} + +// Status page +async function generatePanelStatus(client, targetUser, page = 1) { + const LoaRequest = client.models['staff-management-system']['LoaRequest']; + const allStatuses = await LoaRequest.findAll({ + where: { userId: targetUser.id } + }); + const count = allStatuses.length; + + let totalPages = 1; + if (count > 3) totalPages = 1 + Math.ceil((count - 3) / 5); + const limit = page === 1 + ? 3 + : 5; + const offset = page === 1 + ? 0 + : 3 + ((page - 2) * 5); + + const activeStatus = allStatuses.find(s => ['APPROVED', 'PENDING'].includes(s.status) && new Date(s.endDate) > new Date()); + let activeText = localize('staff-management-system', 'info-none'); + if (activeStatus) { + activeText = `**${activeStatus.type}** (${activeStatus.status})\n${localize('staff-management-system', 'label-end')}: `; + } + + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'p-sta-title', { + username: targetUser.username + })) + .setColor('Green') + .setThumbnail(targetUser.displayAvatarURL({ dynamic: true })) + ); + + let desc = localize('staff-management-system', 'p-sta-desc', { + count: count, active: activeText + }); + + const rows = await LoaRequest.findAll({ + where: { userId: targetUser.id }, + order: [['createdAt', 'DESC']], + limit, + offset + }); + if (rows.length === 0) desc += localize('staff-management-system', 'p-no-hist'); + else { + const icons = { + APPROVED: '✅', + DENIED: '❌', + ENDED: 'âšī¸', + PENDING: '🕐' + }; + desc += rows.map(r => `**${icons[r.status] || '❓'} ${r.type} - ${r.status}**\n**${localize('staff-management-system', 'general-start')}:** \n**${localize('staff-management-system', 'general-end')}:** \n**${localize('staff-management-system', 'general-rsn')}:** ${r.reason}`).join('\n\n'); + } + + embed.setDescription(desc); + embed.addFields({ + name: '\u200b', + value: localize('staff-management-system', 'page-count', { + page, + total: totalPages + }) + }); + + const menu = ActionRowBuilder.from((await generateUserPanel(client, targetUser)).components[0]); + menu.components[0].options.find(opt => opt.data.value === 'status').data.default = true; + + const paginationRow = buildPaginationRow( + `staff-mgmt_panel-stat_${targetUser.id}_${page - 1}`, + 'panel_stat_count', + `staff-mgmt_panel-stat_${targetUser.id}_${page + 1}`, + page, totalPages + ); + + return { + embeds: [embed.toJSON()], + components: [menu.toJSON(), paginationRow.toJSON()] + }; +} + +// Activity checks page +async function generatePanelActivity(client, targetUser, page = 1) { + const ActivityCheck = client.models['staff-management-system']['ActivityCheck']; + const allChecks = await ActivityCheck.findAll(); + + let userResponses = 0; + const historyRows = []; + allChecks.forEach(check => { + const responded = JSON.parse(check.respondedUsers || '[]'); + if (responded.includes(targetUser.id)) { + userResponses++; + historyRows.push(check); + } + }); + + historyRows.sort((a, b) => b.createdAt - a.createdAt); + const count = historyRows.length; + + let totalPages = 1; + if (count > 3) totalPages = 1 + Math.ceil((count - 3) / 5); + const limit = page === 1 + ? 3 + : 5; + const offset = page === 1 + ? 0 + : 3 + ((page - 2) * 5); + const paginatedRows = historyRows.slice(offset, offset + limit); + + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'p-act-title', { + username: targetUser.username + })) + .setColor('Blue') + .setThumbnail(targetUser.displayAvatarURL({ dynamic: true })) + ); + + let desc = localize('staff-management-system', 'p-act-desc', { count: userResponses }); + + if (paginatedRows.length === 0) desc += localize('staff-management-system', 'p-no-hist'); + else { + desc += paginatedRows.map(r => `**${localize('staff-management-system', 'label-chk')} **\n**${localize('staff-management-system', 'label-end')}:** \n**${localize('staff-management-system', 'label-chan')}:** <#${r.channelId}>`).join('\n\n'); + } + + embed.setDescription(desc); + embed.addFields({ name: '\u200b', value: localize('staff-management-system', 'page-count', { page, total: totalPages }) }); + + const menu = ActionRowBuilder.from((await generateUserPanel(client, targetUser)).components[0]); + menu.components[0].options.find(opt => opt.data.value === 'activity').data.default = true; + + const paginationRow = buildPaginationRow( + `staff-mgmt_panel-act_${targetUser.id}_${page - 1}`, + 'panel_act_count', + `staff-mgmt_panel-act_${targetUser.id}_${page + 1}`, + page, totalPages + ); + + return { + embeds: [embed.toJSON()], + components: [menu.toJSON(), paginationRow.toJSON()] + }; +} + +// Shifts page +async function generatePanelShifts(client, targetUser) { + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'p-shi-title', { + username: targetUser.username + })) + .setColor('Purple') + .setThumbnail(targetUser.displayAvatarURL({ dynamic: true })) + ); + + try { + const Shift = client.models['staff-management-system']['StaffShift']; + const config = getConfig(client, 'shifts') || {}; + const shifts = await Shift.findAll({ + where: { + userId: targetUser.id, + endTime: { [Op.not]: null }, + duration: { [Op.not]: null } + } + }); + + const totalShifts = shifts.length; + const totalSeconds = shifts.reduce((sum, s) => sum + (parseInt(s.duration) || 0), 0); + + const breakdown = {}; + shifts.forEach(log => { + const t = log.type || 'Staff'; + breakdown[t] = (breakdown[t] || 0) + (parseInt(log.duration) || 0); + }); + const breakdownStr = Object.entries(breakdown).sort((a, b) => b[1] - a[1]).map(([type, sec]) => `â€ĸ ${type}: ${formatDuration(sec)}`).join('\n') || localize('staff-management-system', 'info-none'); + + let quotaStr = localize('staff-management-system', 'no-quota-configured'); + const guild = client.guilds.cache.get(client.guildID); + const member = await guild?.members.fetch(targetUser.id).catch(() => null); + + if (member && config.enableQuotas && config.quotas) { + let bestQuota = null; + let highestPosition = -1; + for (const [roleId, hoursStr] of Object.entries(config.quotas)) { + const hours = parseFloat(hoursStr); + const role = guild.roles.cache.get(roleId); + if (role && member.roles.cache.has(roleId) && role.position > highestPosition) { + highestPosition = role.position; + bestQuota = { hours }; + } + } + + if (bestQuota) { + const timeframe = config.quotaTimeframe || 'Weekly'; + const cutoff = new Date(); + if (timeframe === 'Weekly') cutoff.setDate(cutoff.getDate() - 7); + else cutoff.setMonth(cutoff.getMonth() - 1); + + const recentShifts = await Shift.findAll({ + where: { + userId: targetUser.id, + startTime: { [Op.gt]: cutoff }, + endTime: { [Op.not]: null }, + duration: { [Op.not]: null } + } + }); + const recentSeconds = recentShifts.reduce((sum, s) => sum + (parseInt(s.duration) || 0), 0); + const requiredSeconds = bestQuota.hours * 3600; + const isMet = recentSeconds >= requiredSeconds; + + quotaStr = localize('staff-management-system', 'duty-quota-str', { + timeframe, + duration: formatDuration(recentSeconds), + hours: bestQuota.hours, + result: isMet + ? localize('staff-management-system', 'duty-quota-met') + : localize('staff-management-system', 'duty-quota-failed') + }); + } + } + + const allResults = await Shift.findAll({ + attributes: ['userId', [Shift.sequelize.fn('SUM', Shift.sequelize.col('duration')), 'totalDuration']], + where: { endTime: { [Op.not]: null }, duration: { [Op.not]: null } }, + group: ['userId'], + order: [[Shift.sequelize.literal('totalDuration'), 'DESC']] + }); + + const lbIndex = allResults.findIndex(p => p.userId === targetUser.id); + const lbRank = lbIndex !== -1 + ? `${lbIndex + 1} / ${allResults.length}` + : localize('staff-management-system', 'label-unranked'); + + embed.setDescription(localize('staff-management-system', 'panel-shifts-desc', { + totalShifts, + totalSeconds: formatDuration(totalSeconds), + lbRank, + breakdownStr, + quotaStr + })); + + } catch (e) { + client.logger.error(`[Staff Management] User panel error: ${e.stack}`); + embed.setDescription(localize('staff-management-system', 'err-shift-data-unavailable', { error: e.message })); + } + + const menu = ActionRowBuilder.from((await generateUserPanel(client, targetUser)).components[0]); + menu.components[0].options.find(opt => opt.data.value === 'shifts').data.default = true; + + const historyBtnRow = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`duty-mgmt_hist_${targetUser.id}_1_All`) + .setLabel(localize('staff-management-system', 'btn-view-history')) + .setStyle(ButtonStyle.Secondary) + ); + + return { + embeds: [embed.toJSON()], + components: [menu.toJSON(), historyBtnRow.toJSON()] + }; +} + +// Deletion page +async function generatePanelDeletion(client, targetUser) { + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'panel-deletion-title', { tag: targetUser.username })) + .setDescription(localize('staff-management-system', 'panel-deletion-desc', { mention: targetUser.toString() })) + .setColor('DarkRed') + .setThumbnail(targetUser.displayAvatarURL({ dynamic: true })) + ); + + const menu = new StringSelectMenuBuilder() + .setCustomId(`staff-mgmt_delete-menu_${targetUser.id}`) + .setPlaceholder(localize('staff-management-system', 'panel-deletion-placeholder')) + .addOptions( + new StringSelectMenuOptionBuilder() + .setLabel(localize('staff-management-system', 'panel-opt-back')) + .setValue('back') + .setEmoji('â—€ī¸'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('staff-management-system', 'panel-opt-del-act')) + .setValue('del_activity') + .setEmoji('📋'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('staff-management-system', 'panel-opt-del-inf')) + .setValue('del_infractions') + .setEmoji('âš ī¸'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('staff-management-system', 'panel-opt-del-prom')) + .setValue('del_promotions') + .setEmoji('🎉'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('staff-management-system', 'panel-opt-del-rev')) + .setValue('del_reviews') + .setEmoji('⭐'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('staff-management-system', 'panel-opt-del-shifts')) + .setValue('del_shifts') + .setEmoji('âąī¸'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('staff-management-system', 'panel-opt-del-status')) + .setValue('del_status') + .setEmoji('🌙'), + new StringSelectMenuOptionBuilder() + .setLabel(localize('staff-management-system', 'panel-opt-del-all')) + .setValue('del_all') + .setEmoji('đŸ’Ĩ') + ); + + return { + embeds: [embed.toJSON()], + components: [new ActionRowBuilder().addComponents(menu).toJSON()] + }; +} + +async function executeDataDeletion(client, targetId, dataType) { + const models = client.models['staff-management-system']; + + if (['del_infractions', 'del_all'].includes(dataType)) await models['Infraction'].destroy({ + where: { userId: targetId } + }); + if (['del_promotions', 'del_all'].includes(dataType)) await models['Promotion'].destroy({ + where: { userId: targetId } + }); + if (['del_reviews', 'del_all'].includes(dataType)) await models['StaffReview'].destroy({ + where: { targetId: targetId } + }); + if (['del_shifts', 'del_all'].includes(dataType)) { + await models['StaffShift'].destroy({ + where: { userId: targetId } + }); + await models['StaffProfile'].destroy({ + where: { userId: targetId } + }); + } + if (['del_status', 'del_all'].includes(dataType)) await models['LoaRequest'].destroy({ + where: { userId: targetId } + }); + if (['del_activity', 'del_all'].includes(dataType)) { + const allChecks = await models['ActivityCheck'].findAll(); + for (const check of allChecks) { + let responded = JSON.parse(check.respondedUsers || '[]'); + if (responded.includes(targetId)) { + responded = responded.filter(id => id !== targetId); + await check.update({ respondedUsers: JSON.stringify(responded) }); + } + } + } +} + +// ----- Status ----- +const getStatusMeta = (type) => ({ + isLoa: type === 'LOA', + label: type === 'LOA' + ? 'LoA' + : 'RA', + enableKey: type === 'LOA' + ? 'enableLoa' + : 'enableRa', + roleKey: type === 'LOA' + ? 'loaRole' + : 'raRole', + maxDaysKey: type === 'LOA' + ? 'loaMaxDays' + : 'raMaxDays', + color: type === 'LOA' + ? 'Green' + : 'Orange', + activeText: localize('staff-management-system', type === 'LOA' + ? 'status-active-loa' + : 'status-active-ra' + ), + histTitle: localize('staff-management-system', type === 'LOA' + ? 'status-hist-loa' + : 'status-hist-ra' + ), + actionPrefix: type === 'LOA' + ? 'loa' + : 'ra' +}); + +async function handleStatusRequest(client, interaction, type, durationInput, reason) { + const config = getConfig(client, 'status'); + const isLoa = type === 'LOA'; + if (!config[isLoa + ? 'enableLoa' + : 'enableRa']) return interaction.editReply({ + content: localize('staff-management-system', 'err-status-disabled', { type }) + } + ); + + const days = parseDurationToDays(durationInput?.trim()); + if (!days || isNaN(days) || days <= 0) return interaction.editReply({ + content: localize('staff-management-system', 'err-invalid-duration') + }); + + const maxDays = (isLoa ? config.loaMaxDays : config.raMaxDays) || (isLoa ? 60 : 30); + if (days > maxDays) return interaction.editReply({ + content: localize('staff-management-system', 'err-duration-max', { max: maxDays }) + }); + + const LoaRequest = client.models['staff-management-system']['LoaRequest']; + if (await LoaRequest.findOne({ + where: { userId: interaction.user.id, type, status: { [Op.in]: ['PENDING', 'APPROVED'] }, + endDate: { [Op.gt]: new Date() } } + })) { + return interaction.editReply({ + content: localize('staff-management-system', 'err-status-exists', { type }) + }); + } + + const startDate = new Date(); + const endDate = new Date(startDate.getTime() + days * 24 * 60 * 60 * 1000); + const needsApproval = isLoa + ? config.requireLoaApproval !== false + : config.requireRaApproval !== false; + + const req = await LoaRequest.create({ + userId: interaction.user.id, + type, + reason, + startDate, + endDate, + status: needsApproval + ? 'PENDING' + : 'APPROVED' + }); + + const logChannelId = getSafeChannelId(config.statusLogChannel); + if (logChannelId && needsApproval) { + const channel = await interaction.guild.channels.fetch(logChannelId).catch(() => null); + if (channel) { + const embed = new EmbedBuilder() + .setTitle(localize('staff-management-system', 'status-request-title', { type })) + .setColor('Blue') + .setAuthor({ name: `Request ID: ${req.id}`}) + .addFields( + { name: localize('staff-management-system', 'status-req-user'), + value: interaction.user.toString(), + inline: true + }, + { name: localize('staff-management-system', 'status-req-duration'), + value: `${days} ${localize('staff-management-system', 'label-days')}`, + inline: true + }, + { name: localize('staff-management-system', 'general-rsn'), + value: reason + } + ); + + applyFooter(client, embed); + const row = new ActionRowBuilder() + .addComponents(new ButtonBuilder() + .setCustomId(`staff-mgmt_approve_${req.id}`) + .setLabel(localize('staff-management-system', 'btn-approve')) + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(`staff-mgmt_deny_${req.id}`) + .setLabel(localize('staff-management-system', 'btn-deny')) + .setStyle(ButtonStyle.Danger)); + channel.send({ embeds: [embed.toJSON()], components: [row.toJSON()] }).catch(()=>{}); + } + } + + if (!needsApproval) { + const roleId = config[isLoa ? 'loaRole' : 'raRole']; + if (roleId) interaction.member.roles.add(roleId).catch(()=>{}); + await logStatusChange(client, type, 'start', { + targetUser: interaction.user, + startDate, + endDate, + reason, + approverId: null + }); + } + + await interaction.editReply({ + content: localize('staff-management-system', 'success-status-request', { + type, state: needsApproval + ? localize('staff-management-system', 'state-pending') + : localize('staff-management-system', 'state-auto') + }) + }); +} + +async function handleStatusView(client, interaction, type, targetUser) { + const user = targetUser || interaction.user; + const request = await client.models['staff-management-system']['LoaRequest'].findOne({ + where: { userId: user.id, type, status: { [Op.in]: ['APPROVED', 'PENDING'] }, + endDate: { [Op.gt]: new Date() } }, + order: [['createdAt', 'DESC']] + }); + + if (!request) return interaction.editReply({ + content: localize('staff-management-system', 'no-active-status', { + user: user.username, + type + }) + }); + + const embed = new EmbedBuilder() + .setTitle(`${type} Status: ${user.username}`) + .setColor(request.status === 'APPROVED' + ? 'Green' + : 'Yellow' + ) + .addFields( + { + name: localize('staff-management-system', 'label-stat'), + value: request.status, + inline: true }, + { + name: localize('staff-management-system', 'label-end'), + value: formatDate(request.endDate), + inline: true }, + { + name: localize('staff-management-system', 'general-rsn'), + value: request.reason || localize('staff-management-system', 'info-none') + }) + .setThumbnail(user.displayAvatarURL({ dynamic: true })); + applyFooter(client, embed); + await interaction.editReply({ embeds: [embed.toJSON()] }); +} + +async function handleStatusList(client, interaction, type, filter, page = 1) { + const limit = 10; + const offset = (page - 1) * limit; + + let whereClause = { type }; + let title = `${type} List`; + + if (filter === 'active') { + whereClause.status = 'APPROVED'; + whereClause.endDate = { [Op.gt]: new Date() }; + title += localize('staff-management-system', 'filter-active'); + } + else if (filter === 'expired') { + whereClause.endDate = { [Op.lt]: new Date() }; + title += localize('staff-management-system', 'filter-expired'); + } + else { + whereClause.status = { [Op.ne]: 'PENDING' }; + title += localize('staff-management-system', 'filter-history'); + } + + const { count, rows } = await client.models['staff-management-system']['LoaRequest'].findAndCountAll({ + where: whereClause, + limit, + offset, + order: [['endDate', 'DESC']] + }); + if (count === 0) return interaction.editReply({ + content: localize('staff-management-system', 'err-no-recs') + }); + + const totalPages = Math.ceil(count / limit) || 1; + const embed = new EmbedBuilder() + .setTitle(title) + .setColor('Blue') + .setDescription(rows.map(r => `**<@${r.userId}>** ${r.status === 'APPROVED' ? '✅' : (r.status === 'DENIED' ? '❌' : 'âšī¸')}\nEnds: ${formatDate(r.endDate)}\nReason: ${r.reason}`).join('\n\n')) + .addFields( + { + name: '\u200b', + value: localize('staff-management-system', 'page-count', { page, total: totalPages }) + } + ); + applyFooter(client, embed); + await interaction.editReply({ embeds: [embed.toJSON()] }); +} + +async function handleStatusManage(client, interaction, targetMember, type) { + const config = getConfig(client, 'status'); + const meta = getStatusMeta(type); + if (!config[meta.enableKey]) return interaction.editReply({ + content: localize('staff-management-system', 'err-status-disabled', { type }) + }); + + const generalConfig = getConfig(client, 'configuration'); + const canManage = interaction.member.roles.cache.some(r => [...(generalConfig.supervisorRoles || []), ...(generalConfig.managementRoles || [])].includes(r.id)) || interaction.member.permissions.has('Administrator'); + if (!canManage) return interaction.editReply({ + content: localize('staff-management-system', 'err-gen-no-perm') + }); + + const LoaRequest = client.models['staff-management-system']['LoaRequest']; + const activeRequest = await LoaRequest.findOne({ + where: { + userId: targetMember.user.id, + type, + status: { [Op.in]: ['APPROVED', 'PENDING'] }, + endDate: { [Op.gt]: new Date() } + }, + order: [['createdAt', 'DESC']] + } + ); + const totalCount = await LoaRequest.count({ + where: { userId: targetMember.user.id, type } + }); + + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'manage-status-title', { + label: meta.label, + username: targetMember.user.username + })) + .setThumbnail(targetMember.user.displayAvatarURL({ dynamic: true })) + .setColor(activeRequest + ? meta.color + : 'Grey' + ) + .setDescription(localize('staff-management-system', 'manage-stat-desc', { + status: activeRequest + ? meta.activeText + : localize('staff-management-system', 'no-act-stat', { + label: meta.label + }), + label: meta.label, + count: Math.max(0, totalCount - (activeRequest ? 1 : 0)) + })) + ); + + embed.addFields({ + name: localize('staff-management-system', 'manage-active-details', { label: meta.label }), + value: activeRequest ? `**${localize('staff-management-system', 'general-start')}:** ${formatDate(activeRequest.startDate)}\n**${localize('staff-management-system', 'general-end')}:** ${formatDate(activeRequest.endDate)}\n**${localize('staff-management-system', 'label-stat')}:** ${activeRequest.status}\n**${localize('staff-management-system', 'label-appr-by')}:** ${activeRequest.approverId ? `<@${activeRequest.approverId}>` : localize('staff-management-system', 'label-auto')}\n**${localize('staff-management-system', 'general-rsn')}:** ${activeRequest.reason || localize('staff-management-system', 'info-none')}` : localize('staff-management-system', 'manage-no-active-user', { label: meta.label }) + }); + + const p = meta.actionPrefix; + const rid = activeRequest?.id ?? 'none'; + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`staff-mgmt_${p}-end_${rid}`) + .setLabel(localize('staff-management-system', 'btn-end-early', { label: meta.label })) + .setEmoji('đŸšĢ').setStyle(ButtonStyle.Danger) + .setDisabled(!activeRequest), + new ButtonBuilder() + .setCustomId(`staff-mgmt_${p}-extend_${rid}`) + .setLabel(localize('staff-management-system', 'btn-extend', { label: meta.label })) + .setEmoji('âŗ') + .setStyle(ButtonStyle.Primary) + .setDisabled(!activeRequest), + new ButtonBuilder() + .setCustomId(`staff-mgmt_${p}-hist_${targetMember.user.id}_1`) + .setLabel(localize('staff-management-system', 'btn-view-history')) + .setEmoji('📜') + .setStyle(ButtonStyle.Secondary) + .setDisabled(totalCount === 0) + ); + await interaction.editReply({ + embeds: [embed.toJSON()], + components: [row.toJSON()] + }); +} + +async function handleStatusEnd(interaction, type) { + const meta = getStatusMeta(type); + const requestId = interaction.customId.split('_')[2]; + if (requestId === 'none') return interaction.reply({ + content: localize('staff-management-system', 'err-no-active-end', { label: meta.label }), + flags: MessageFlags.Ephemeral + }); + + const modal = new ModalBuilder() + .setCustomId(`staff-mgmt_${meta.actionPrefix}-end-submit_${requestId}`) + .setTitle(localize('staff-management-system', 'modal-end-early-title', { label: meta.label })); + modal.addComponents(new ActionRowBuilder() + .addComponents( + new TextInputBuilder() + .setCustomId('end_reason') + .setLabel(localize('staff-management-system', 'modal-end-early-reason')) + .setStyle(TextInputStyle.Paragraph) + .setRequired(true) + )); + return interaction.showModal(modal); +} + +async function handleStatusEndSubmit(client, interaction, type) { + const meta = getStatusMeta(type); + const request = await client.models['staff-management-system']['LoaRequest'].findByPk(interaction.customId.split('_')[2]); + if (!request || request.status === 'ENDED' || request.status === 'DENIED') return interaction.reply({ + content: localize('staff-management-system', 'err-stat-inact', { label: meta.label }), + flags: MessageFlags.Ephemeral + }); + + const reason = interaction.fields.getTextInputValue('end_reason'); + const member = await interaction.guild.members.fetch(request.userId).catch(() => null); + + if (member && getConfig(client, 'status')[meta.roleKey]) await member.roles.remove(getConfig(client, 'status')[meta.roleKey]).catch(() => {}); + + await request.update({ status: 'ENDED', endDate: new Date() }); + await client.models['staff-management-system']['StaffProfile'].update({ activityStatus: 'ACTIVE' }, { + where: { userId: request.userId } + }); + + if (member) await sendStatusDm(member.user, type, 'ended_early', { + ender: interaction.user.tag, + reason + }); + await logStatusChange(client, type, 'end', { + userId: request.userId, + startDate: request.startDate, + reason: request.reason + }); + + const updatedEmbed = EmbedBuilder.from(interaction.message.embeds[0]) + .setColor('Grey') + .setDescription(localize('staff-management-system', 'status-ended-embed-desc', { + label: meta.label, user: interaction.user.tag, reason + })) + .spliceFields(0, 1, { + name: localize('staff-management-system', 'manage-active-details', { label: meta.label }), + value: localize('staff-management-system', 'manage-no-active-user', { label: meta.label }) + }); + + const p = meta.actionPrefix; + const disabledRow = new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId(`${p}-end-done`) + .setLabel(localize('staff-management-system', 'btn-end-early', { label: meta.label })) + .setEmoji('đŸšĢ') + .setStyle(ButtonStyle.Danger) + .setDisabled(true), + new ButtonBuilder() + .setCustomId(`${p}-extend-done`) + .setLabel(localize('staff-management-system', 'btn-extend', { label: meta.label })) + .setEmoji('âŗ') + .setStyle(ButtonStyle.Primary) + .setDisabled(true), + new ButtonBuilder() + .setCustomId(`staff-mgmt_${p}-hist_${request.userId}_1`) + .setLabel(localize('staff-management-system', 'btn-view-history')) + .setEmoji('📜') + .setStyle(ButtonStyle.Secondary) + ); + return interaction.update({ + embeds: [updatedEmbed.toJSON()], + components: [disabledRow.toJSON()] + }); +} + +async function handleStatusExtend(interaction, type) { + const meta = getStatusMeta(type); + const requestId = interaction.customId.split('_')[2]; + if (requestId === 'none') return interaction.reply({ + content: localize('staff-management-system', 'err-no-active-extend', { label: meta.label }), + flags: MessageFlags.Ephemeral + }); + + const modal = new ModalBuilder() + .setCustomId(`staff-mgmt_${meta.actionPrefix}-extend-submit_${requestId}`) + .setTitle(localize('staff-management-system', 'modal-extend-title', { + label: meta.label + })); + modal.addComponents( + new ActionRowBuilder() + .addComponents( + new TextInputBuilder() + .setCustomId('extend_days') + .setLabel(localize('staff-management-system', 'modal-extend-days')) + .setStyle(TextInputStyle.Short) + .setPlaceholder("7") + .setRequired(true) + ), + new ActionRowBuilder() + .addComponents( + new TextInputBuilder() + .setCustomId('extend_reason') + .setLabel(localize('staff-management-system', 'modal-extend-reason')) + .setStyle(TextInputStyle.Paragraph) + .setRequired(true) + ) + ); + return interaction.showModal(modal); +} + +function scheduleStatusExpiry(client, request) { + schedule.scheduleJob(new Date(request.endDate), async () => { + try { + const req = await client.models['staff-management-system']['LoaRequest'].findByPk(request.id); + if (req && req.status === 'APPROVED' && new Date(req.endDate) <= new Date()) { + await req.update({ status: 'ENDED' }); + await client.models['staff-management-system']['StaffProfile'].update({ activityStatus: 'ACTIVE' }, { + where: { userId: req.userId } + }); + + const member = await client.guilds.cache.get(client.guildID)?.members.fetch(req.userId).catch(() => null); + if (member) { + const roleKey = req.type === 'LOA' + ? 'loaRole' + : 'raRole'; + if (getConfig(client, 'status')[roleKey]) await member.roles.remove(getConfig(client, 'status')[roleKey]).catch(() => null); + await sendStatusDm(member.user, req.type, 'ended'); + } + await logStatusChange(client, req.type, 'end', { userId: req.userId, startDate: req.startDate, reason: req.reason }); + } + } catch (e) {} + }); +} + +async function handleStatusExtendSubmit(client, interaction, type) { + const meta = getStatusMeta(type); + const request = await client.models['staff-management-system']['LoaRequest'].findByPk(interaction.customId.split('_')[2]); + if (!request || request.status === 'ENDED' || request.status === 'DENIED') return interaction.reply({ + content: localize('staff-management-system', 'err-stat-inact', { + label: meta.label + }), + flags: MessageFlags.Ephemeral + }); + + const days = parseInt(interaction.fields.getTextInputValue('extend_days'), 10); + const reason = interaction.fields.getTextInputValue('extend_reason'); + if (isNaN(days) || days <= 0 || days > 180) return interaction.reply({ + content: localize('staff-management-system', 'err-inv-dur'), + flags: MessageFlags.Ephemeral + }); + + const oldEndDate = new Date(request.endDate); + const newEndDate = new Date(oldEndDate.getTime() + days * 24 * 60 * 60 * 1000); + await request.update({ endDate: newEndDate }); + scheduleStatusExpiry(client, request); + + const member = await interaction.guild.members.fetch(request.userId).catch(() => null); + if (member) await sendStatusDm(member.user, type, 'extended', { + extender: interaction.user.tag, + days, + endDate: newEndDate, + reason + }); + await logStatusChange(client, type, 'adjusted', { + userId: request.userId, + executorId: interaction.user.id, + changesText: localize('staff-management-system', 'status-adjusted-log', { + label: meta.label, + newEnd: ``, + oldEnd: ``, + reason + }) + }); + + const updatedEmbed = EmbedBuilder.from(interaction.message.embeds[0]) + .spliceFields(0, 1, { + name: localize('staff-management-system', 'manage-active-details', { label: meta.label }), + value: localize('staff-management-system', 'mod-stat-ext', { + s: formatDate(request.startDate), + e: formatDate(newEndDate), + d: days, + t: request.status, + a: request.approverId + ? `<@${request.approverId}>` + : localize('staff-management-system', 'label-auto'), + r: request.reason || localize('staff-management-system', 'info-none') + }) + }); + return interaction.update({ + embeds: [updatedEmbed.toJSON()], + components: interaction.message.components.map(c => c.toJSON()) + }); +} + +async function generateStatusHistoryResponse(client, targetUser, page = 1, type) { + const meta = getStatusMeta(type); + const limit = 5; + const offset = (page - 1) * limit; + + const { count, rows } = await client.models['staff-management-system']['LoaRequest'].findAndCountAll({ + where: { userId: targetUser.id, type }, + order: [['createdAt', 'DESC']], + limit, + offset + }); + if (count === 0) return { + content: localize('staff-management-system', 'info-no-status-history', { label: meta.label }), + flags: MessageFlags.Ephemeral + }; + + const totalPages = Math.ceil(count / limit) || 1; + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(`${meta.histTitle} - ${targetUser.username}`) + .setThumbnail(targetUser.displayAvatarURL({ dynamic: true })) + .setColor(meta.color) + .setDescription(localize('staff-management-system', 'status-history-desc', { + count: rows.length, + total: count, + label: meta.label + } + )) + ); + + const statusIcons = { + APPROVED: '✅', + DENIED: '❌', + ENDED: 'âšī¸', + PENDING: '🕐' + }; + rows.forEach((req, index) => embed.addFields({ + name: `${statusIcons[req.status] ?? '❓'} ${meta.label} #${offset + index + 1} - ${req.status}`, + value: `**${localize('staff-management-system', 'general-start')}:** ${formatDate(req.startDate)}\n**${localize('staff-management-system', 'general-end')}:** ${formatDate(req.endDate)}\n**${localize('staff-management-system', 'label-appr-by')}:** ${req.approverId ? `<@${req.approverId}>` : localize('staff-management-system', 'label-auto')}\n**${localize('staff-management-system', 'general-rsn')}:** ${req.reason || localize('staff-management-system', 'info-none')}` })); + embed.addFields({ + name: '\u200b', + value: localize('staff-management-system', 'page-count', { page, total: totalPages }) + }); + + const row = buildPaginationRow( + `staff-mgmt_${meta.actionPrefix}-hist_${targetUser.id}_${page - 1}`, + `${meta.actionPrefix}_hist_page_count`, + `staff-mgmt_${meta.actionPrefix}-hist_${targetUser.id}_${page + 1}`, + page, + totalPages + ); + return { + embeds: [embed.toJSON()], + components: [row.toJSON()] + }; +} + +async function handleStatusHistPage(client, interaction, type) { + const parts = interaction.customId.split('_'); + const targetUser = await client.users.fetch(parts[2]).catch(() => null); + if (!targetUser) return interaction.reply({ + content: localize('staff-management-system', 'err-gen-no-user'), + flags: MessageFlags.Ephemeral + }); + + const payload = await generateStatusHistoryResponse(client, targetUser, parseInt(parts[3], 10), type); + if (payload.content) return interaction.reply({ + ...payload, + flags: MessageFlags.Ephemeral + }); + return interaction.message?.embeds?.[0]?.title?.startsWith(getStatusMeta(type).histTitle) + ? interaction.update(payload) + : interaction.reply({ ...payload, flags: MessageFlags.Ephemeral }); +} + +// ---------- Activity Checks ---------- +async function startActivityCheck(client, interactionOrChannel, isAutomated = false) { + const config = getConfig(client, 'activity-checks'); + const ActivityCheck = client.models['staff-management-system']['ActivityCheck']; + + if (await ActivityCheck.findOne({ + where: { status: 'ACTIVE' } + })) { + return !isAutomated && interactionOrChannel.editReply + ? interactionOrChannel.editReply({ content: localize('staff-management-system', 'err-ac-act') }) + : null; + } + + let rolesToCheck = config.targetRoles?.length + ? config.targetRoles + : (getConfig(client, 'configuration')?.staffRoles || []); + if (!rolesToCheck.length) return !isAutomated && interactionOrChannel.editReply + ? interactionOrChannel.editReply({ + content: localize('staff-management-system', 'err-ac-norole') + }) + : null; + + const targetChannel = isAutomated + ? interactionOrChannel + : (interactionOrChannel.options.getChannel('channel') || interactionOrChannel.guild.channels.cache.get(getSafeChannelId(config.sendingChannel)) || interactionOrChannel.channel); + if (!targetChannel) return !isAutomated && interactionOrChannel.editReply + ? interactionOrChannel.editReply({ + content: localize('staff-management-system', 'err-ac-invchan') + }) + : null; + + const durationHours = config.timeframe || 24; + const endTime = new Date(Date.now() + durationHours * 60 * 60 * 1000); + + let embedTemplate = typeof config.checkMessage === 'string' + ? JSON.parse(config.checkMessage) + : config.checkMessage; + let msgOpts = await embedTypeV2(embedTemplate, { + '%endtime%': ``, + '%duration%': durationHours.toString() + }); + + if (msgOpts?.content?.trim() === '') delete msgOpts.content; + msgOpts.components = [ + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId('staff-mgmt_ac-respond') + .setLabel(localize('staff-management-system', 'ac-confirm-btn')) + .setStyle(ButtonStyle.Success) + .setEmoji('✅') + ) + .toJSON() + ]; + + try { + const checkMessage = await targetChannel.send(msgOpts); + if (!isAutomated && interactionOrChannel.editReply) await interactionOrChannel.editReply({ + content: localize('staff-management-system', 'succ-ac-start', { + channel: targetChannel.id, + hours: durationHours + }) + }); + + const record = await ActivityCheck.create({ + messageId: checkMessage.id, + channelId: targetChannel.id, + endTime, + targetRoles: JSON.stringify(rolesToCheck), + respondedUsers: '[]', + status: 'ACTIVE' + }); + schedule.scheduleJob(endTime, async () => { + const currentCheck = await ActivityCheck.findByPk(record.id); + if (currentCheck && currentCheck.status === 'ACTIVE') await endActivityCheckProcess(client, currentCheck); + }); + } catch (e) { + if (!isAutomated && interactionOrChannel.editReply) interactionOrChannel.editReply({ + content: localize('staff-management-system', 'err-ac-perms', { channel: targetChannel.id }) + }); + } +} + +async function endActivityCheckProcess(client, activeCheck) { + await activeCheck.update({ status: 'ENDED' }); + const guild = client.guilds.cache.get(client.guildID); + if (!guild) return; + + try { + const msg = await guild.channels.cache.get(activeCheck.channelId)?.messages.fetch(activeCheck.messageId); + if (msg && msg.embeds.length > 0) { + const originalEmbed = EmbedBuilder + .from(msg.embeds[0]) + .setColor('#ed4245'); + originalEmbed + .setTitle(localize('staff-management-system', 'ac-title-end')); + await msg.edit({ + embeds: [originalEmbed.toJSON()], + components: [] + }); + } + } catch (e) {} + + const config = getConfig(client, 'activity-checks'); + const logChannel = guild.channels.cache.get(getSafeChannelId(config.logChannel) || getSafeChannelId(getConfig(client, 'configuration')?.generalLogChannel)); + if (!logChannel) return; + + const targetRoles = JSON.parse(activeCheck.targetRoles || '[]'); + const respondedUsers = JSON.parse(activeCheck.respondedUsers || '[]'); + + const expectedMembers = guild.members.cache.filter(m => !m.user.bot && m.roles.cache.some(r => targetRoles.includes(r.id))); + const [responded, exceptions, failed] = [[], [], []]; + const profiles = await client.models['staff-management-system']['StaffProfile'].findAll(); + + expectedMembers.forEach(member => { + if (respondedUsers.includes(member.id)) return responded.push(member); + + let isException = false; + const prof = profiles.find(p => p.userId === member.id); + const isLoa = prof?.activityStatus === 'LOA'; + const isRa = prof?.activityStatus === 'RA'; + + if (config.exceptionsType === 'Only LoA' && isLoa) isException = true; + else if (config.exceptionsType === 'Only RA' && isRa) isException = true; + else if (config.exceptionsType === 'LoA and RA' && (isLoa || isRa)) isException = true; + else if (config.exceptionsType === 'Custom role(s)' && member.roles.cache.some(r => config.customExceptionRoles?.includes(r.id))) isException = true; + + isException + ? exceptions.push(member) + : failed.push(member); + }); + + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'ac-res-title')) + .setColor('Blurple') + .addFields( + { + name: localize('staff-management-system', 'ac-f-res', { + count: responded.length } + ), + value: responded.length + ? responded.map(m => `<@${m.id}>`).join(', ').substring(0, 1024) + : localize('staff-management-system', 'info-none') + }, + { + name: localize('staff-management-system', 'ac-f-fail', { + count: failed.length + }), + value: failed.length + ? failed.map(m => `<@${m.id}>`).join(', ').substring(0, 1024) + : localize('staff-management-system', 'info-none') + }, + { + name: localize('staff-management-system', 'ac-f-exc', { + count: exceptions.length + }), + value: exceptions.length + ? exceptions.map(m => `<@${m.id}>`).join(', ').substring(0, 1024) + : localize('staff-management-system', 'info-none') + } + ) + ); + + const pingText = (config.pingResults && config.pingRoles?.length) + ? config.pingRoles.map(rId => `<@&${rId}>`).join(' ') + : null; + const finalMessage = { embeds: [embed.toJSON()] }; + if (pingText) finalMessage.content = pingText; + + await logChannel.send(finalMessage); +} + +function initActivityCheckAutomation(client) { + const config = getConfig(client, 'activity-checks'); + if (!config?.enableActivityChecks || !config?.automatedChecks) return; + + let cronString = config.automatedCheckInterval === 'Cronjob' + ? config.automatedCheckCronjob + : null; + if (!cronString) { + const dayMap = { + 'Monday': 1, + 'Tuesday': 2, + 'Wednesday': 3, + 'Thursday': 4, + 'Friday': 5, + 'Saturday': 6, + 'Sunday': 7 + }[config.automatedCheckWeekDay] || 1; + if (['Weekly', 'Biweekly'].includes(config.automatedCheckInterval)) cronString = `0 12 * * ${dayMap}`; + else if (config.automatedCheckInterval === 'Monthly') { + const startDay = [1, 8, 15, 22][(config.automatedCheckMonthWeek || 1) - 1]; + cronString = `0 12 ${startDay}-${startDay + 6} * ${dayMap}`; + } + } + if (!cronString) return; + + let toggleWeek = false; + schedule.scheduleJob('automated-activity-check', cronString, async () => { + if (config.automatedCheckInterval === 'Biweekly' && (toggleWeek = !toggleWeek, !toggleWeek)) return; + + const channel = client.guilds.cache.get(client.guildID)?.channels.cache.get(getSafeChannelId(config.sendingChannel)); + if (channel) { + client.logger.info(`[Activity Checks] Starting automated check.`); + await startActivityCheck(client, channel, true); + } + }); +} + +// ---------- Reviews ---------- +async function submitReview(client, interaction, targetUser, stars, comment) { + const config = getConfig(client, 'reviews'); + if (!config?.enableReviews) return interaction.reply({ + content: localize('staff-management-system', 'err-feat-disabled', { + feature: 'Reviews' + }), + flags: MessageFlags.Ephemeral + }); + + const targetMember = await interaction.guild.members.fetch(targetUser.id).catch(() => null); + if (!targetMember) return interaction.reply({ + content: localize('staff-management-system', 'err-not-mem'), + flags: MessageFlags.Ephemeral + }); + if (!config.allowSelfRating && targetUser.id === interaction.user.id) return interaction.reply({ + content: localize('staff-management-system', 'err-self-rate'), + flags: MessageFlags.Ephemeral + }); + + if (config.onlyAllowStaffReview !== false) { + const genCfg = getConfig(client, 'configuration'); + if (!targetMember.roles.cache.some(r => [...(genCfg?.staffRoles || []), ...(genCfg?.supervisorRoles || []), ...(genCfg?.managementRoles || [])].includes(r.id))) { + return interaction.reply({ + content: localize('staff-management-system', 'err-staff-rate'), + flags: MessageFlags.Ephemeral + }); + } + } + + const review = await client.models['staff-management-system']['StaffReview'].create({ + targetId: targetUser.id, + authorId: interaction.user.id, + stars, + comment + }); + const channelId = getSafeChannelId(config.reviewLogChannel); + + if (channelId) { + const channel = interaction.guild.channels.cache.get(channelId); + if (channel) { + let msgOpts = await embedTypeV2(config.ratingMessage, { + '%target%': targetUser.toString(), + '%author%': interaction.user.toString(), + '%stars%': "⭐".repeat(stars), + '%rating%': stars.toString(), + '%comment%': comment, + '%staff-profile-picture%': targetUser.displayAvatarURL({ dynamic: true }), + '%reviewer-profile-picture%': interaction.user.displayAvatarURL({ dynamic: true }) + }); + if (msgOpts?.content?.trim() === '') delete msgOpts.content; + const sentMessage = await channel.send(msgOpts).catch(()=>{}); + if (sentMessage) await review.update({ messageUrl: sentMessage.url }); + } + } + await interaction.reply({ + content: localize('staff-management-system', 'succ-review', { + tag: targetUser.tag, + stars + }), + flags: MessageFlags.Ephemeral + }); +} + +async function generateReviewHistoryResponse(client, targetUser, page = 1) { + if (!getConfig(client, 'reviews')?.enableReviews) return { + content: localize('staff-management-system', 'err-feat-disabled', { + feature: 'Reviews' + }), + flags: MessageFlags.Ephemeral + }; + + const limit = 8; + const offset = (page - 1) * limit; + const Review = client.models['staff-management-system']['StaffReview']; + + const { count, rows } = await Review.findAndCountAll({ + where: { targetId: targetUser.id }, + order: [['createdAt', 'DESC']], + limit, + offset + }); + const allReviews = await Review.findAll({ + where: { targetId: targetUser.id }, + attributes: ['stars'] + }); + const avg = allReviews.length + ? (allReviews.reduce((a, b) => a + b.stars, 0) / allReviews.length).toFixed(1) + : 0; + + const embed = applyFooter(client, new EmbedBuilder() + .setTitle(localize('staff-management-system', 'rev-title', { username: targetUser.username })) + .setColor('Gold') + .setDescription(localize('staff-management-system', 'rev-desc', { avg, count: allReviews.length })) + .setThumbnail(targetUser.displayAvatarURL({ dynamic: true })) + ); + + embed.addFields({ + name: localize('staff-management-system', 'label-hist'), + value: rows.length > 0 + ? rows.map(r => `**${"⭐".repeat(r.stars)}** ${localize('staff-management-system', 'label-by')} <@${r.authorId}>${r.messageUrl + ? ` â€ĸ [Jump](${r.messageUrl})` + : ''}\n"${r.comment}"`).join('\n\n') + : localize('staff-management-system', 'p-no-hist') }); + + const row = buildPaginationRow( + `staff-mgmt_rev-page_${targetUser.id}_${page - 1}`, + 'page_count_disabled', + `staff-mgmt_rev-page_${targetUser.id}_${page + 1}`, + page, + Math.ceil(count / limit) || 1 + ); + return { + embeds: [embed.toJSON()], + components: [row.toJSON()] + }; +} + +async function getReviewHistory(client, interaction, targetUser) { + const response = await generateReviewHistoryResponse(client, targetUser, 1); + if (response.content && response.content.startsWith('❌')) return interaction.reply(response); + await interaction.reply({ + ...response, + flags: MessageFlags.Ephemeral + }); +} + +module.exports = { + logStatusChange, + getConfig, + applyFooter, + buildPaginationRow, + formatDuration, + issueInfraction, + issueSuspension, + getInfractionHistory, + voidInfraction, + generateInfractionHistoryResponse, + promoteUser, + generatePromotionHistoryResponse, + getPromotionHistory, + generateUserPanel, + generatePanelInfractions, + generatePanelPromotions, + generatePanelActivity, + generatePanelReviews, + generatePanelStatus, + generatePanelShifts, + generatePanelDeletion, + executeDataDeletion, + generatePanelSubpage, + handleStatusRequest, + handleStatusView, + handleStatusList, + handleStatusManage, + handleStatusEnd, + handleStatusEndSubmit, + handleStatusExtend, + handleStatusExtendSubmit, + handleStatusHistPage, + startActivityCheck, + initActivityCheckAutomation, + endActivityCheckProcess, + submitReview, + getReviewHistory, + generateReviewHistoryResponse, + sendStatusDm, + scheduleStatusExpiry +}; \ No newline at end of file diff --git a/src/events/interactionCreate.js b/src/events/interactionCreate.js index 99f0554..b2c07ad 100644 --- a/src/events/interactionCreate.js +++ b/src/events/interactionCreate.js @@ -82,7 +82,7 @@ module.exports.run = async (client, interaction) => { })); try { - if (command.options.filter(c => c.type === 'SUB_COMMAND').length === 0) return await command.run(interaction); + if (command.options.filter(c => c.type === 'SUB_COMMAND' || c.type === 'SUB_COMMAND_GROUP').length === 0) return await command.run(interaction); if (!command.subcommands) { interaction.client.logger.error(`Command ${interaction.commandName} has subcommands but does not use the subcommands handler (required).`); return interaction.reply({