diff --git a/code/game/machinery/announcement_system.dm b/code/game/machinery/announcement_system.dm
index b521eb153..5ace33c71 100644
--- a/code/game/machinery/announcement_system.dm
+++ b/code/game/machinery/announcement_system.dm
@@ -102,6 +102,13 @@ GLOBAL_LIST_EMPTY(announcement_systems)
else if(message_type == "ARRIVALS_BROKEN")
message = "The arrivals shuttle has been damaged. Docking for repairs..."
+ //Hyper edit
+ else if (message_type == "ONDUTY")
+ message = CompileText(onduty, user, rank)
+ else if (message_type == "OFFDUTY")
+ message = CompileText(offduty, user, rank)
+ //Hyper edit end
+
if(channels.len == 0)
radio.talk_into(src, message, null)
else
diff --git a/config/config.txt b/config/config.txt
index a5720a805..a481ec955 100644
--- a/config/config.txt
+++ b/config/config.txt
@@ -5,6 +5,8 @@ $include dbconfig.txt
$include comms.txt
$include antag_rep.txt
$include donator_groupings.txt
+# Hyperstation Configs...
+$include hyperstation/jobs.txt
# You can use the @ character at the beginning of a config option to lock it from being edited in-game
# Example usage:
diff --git a/config/hyperstation/jobs.txt b/config/hyperstation/jobs.txt
new file mode 100644
index 000000000..f9353be69
--- /dev/null
+++ b/config/hyperstation/jobs.txt
@@ -0,0 +1,12 @@
+# Allow time off to be enabled
+## Uncomment to allow players to use their accrued time off
+## Defaults to off because this requires the database unless you want them to
+## use only what they accrued during the current round
+#TIME_OFF
+
+# Allow PTO job changes
+## Currently this is required if using the previous to allow users to change
+## jobs. This may be changed in the future and can be used to expand the functionality
+## for something such as on-the-go job changing within the department, without heads.
+## Thus it remains here.
+#PTO_JOB_CHANGE
\ No newline at end of file
diff --git a/hyperstation/code/__DEFINES/misc.dm b/hyperstation/code/__DEFINES/misc.dm
new file mode 100644
index 000000000..245bcc21e
--- /dev/null
+++ b/hyperstation/code/__DEFINES/misc.dm
@@ -0,0 +1,11 @@
+///////////////
+// PTO Types //
+///////////////
+#define PTO_COMMAND "Command"
+#define PTO_SECURITY "Security"
+#define PTO_MEDICAL "Medical"
+#define PTO_ENGINEERING "Engineering"
+#define PTO_SCIENCE "Science"
+#define PTO_CARGO "Cargo"
+#define PTO_CIVILIAN "Civilian"
+#define PTO_CYBORG "Silicon"
diff --git a/hyperstation/code/controllers/configuration/entries/timeoff.dm b/hyperstation/code/controllers/configuration/entries/timeoff.dm
new file mode 100644
index 000000000..04127ed37
--- /dev/null
+++ b/hyperstation/code/controllers/configuration/entries/timeoff.dm
@@ -0,0 +1,11 @@
+/// Toggles time off for jobs
+/datum/config_entry/flag/time_off
+
+/// Toggles changing jobs with PTO
+/datum/config_entry/flag/pto_job_change
+
+/// PTO hour cap
+/datum/config_entry/number/pto_cap
+ config_entry_value = 100 /// Total hours
+ min_val = 0 /// Minimum hourly value
+ integer = FALSE /// We can be a float for partial hours
diff --git a/hyperstation/code/controllers/subsystem/job.dm b/hyperstation/code/controllers/subsystem/job.dm
new file mode 100644
index 000000000..998f228f8
--- /dev/null
+++ b/hyperstation/code/controllers/subsystem/job.dm
@@ -0,0 +1,18 @@
+/**
+ * Some modifications to the jobs subsystem, courtesy of SandPoot.
+ * If we pull from upstream we should have this somewhere at some point, so it's shoehorned
+ * in here for the moment.
+ * Insert unhelpful comment here.
+ */
+
+/datum/controller/subsystem/job/proc/get_job_name(job_name)
+ if (!job_name)
+ return "Unknown" // Invalid job
+ //var/all_alt_titles = get_all_alt_titles()
+ //if (job_name in all_alt_titles) // Check if the name is an alt title
+ //return get_job_name(all_alt_titles[job_name]) // Locate the original job title and return it
+ if (job_name in get_all_job_icons()) // Check if the job has a HUD icon
+ return job_name
+ if (job_name in get_all_centcom_jobs()) // Return the NT logo if it is a CentCom job
+ return "CentCom"
+ return "Unknown" // Return unknown if none of the above apply
diff --git a/hyperstation/code/game/machinery/announcement_system.dm b/hyperstation/code/game/machinery/announcement_system.dm
new file mode 100644
index 000000000..63cbd87de
--- /dev/null
+++ b/hyperstation/code/game/machinery/announcement_system.dm
@@ -0,0 +1,3 @@
+/obj/machinery/announcement_system
+ var/onduty = "%PERSON has moved On-Duty as %RANK."
+ var/offduty = "%PERSON, %RANK, has moved Off-Duty."
diff --git a/hyperstation/code/game/machinery/computer/timeclock.dm b/hyperstation/code/game/machinery/computer/timeclock.dm
new file mode 100644
index 000000000..4801f2bd1
--- /dev/null
+++ b/hyperstation/code/game/machinery/computer/timeclock.dm
@@ -0,0 +1,310 @@
+/// Timeclock terminal, ported from VOREStation
+/obj/machinery/computer/timeclock
+ name = "timeclock terminal" // Name of the object
+ icon = 'hyperstation/icons/obj/machinery/timeclock.dmi' // Spritesheet for the object's icon
+ icon_state = "timeclock" // Icon state from the spritesheet
+ icon_keyboard = null // Keyboard state because this is a computer and we need to tell it to not have a keyboard
+ light_color = "#0099ff" // TODO: Adjust this // Color for the light coming from the object
+ light_power = 0.5 // Brightness of the light coming from the object
+ layer = ABOVE_WINDOW_LAYER // Layer for the object
+ density = FALSE // Density of the object, so we can walk through it
+ circuit = /obj/item/circuitboard/computer/timeclock // Circuitboard for the object in case it gets destroyed
+
+ var/obj/item/card/id/card // Inserted ID card
+
+/// For when we create a new timeclock
+/obj/machinery/computer/timeclock/New()
+ ..() // Let's do this just to be safe.
+
+// For when a timeclock is destroyed
+/obj/machinery/computer/timeclock/Destroy()
+ if (card) // If we're holding an ID and get destroyed
+ card.forceMove(get_turf(src)) // Get rid of the fucker
+ card = null // And make sure we know it's gone
+ . = ..() // Someone's gonna ask later, refer them here.
+ // This shit just sets our return value to our parent proc's return value.
+
+// Determines what icon to used based on a couple factors
+/obj/machinery/computer/timeclock/update_icon()
+ if (!process()) // If we can't process
+ icon_state = "[initial(icon_state)]_off" // we must be offline.
+ else if (card) // If we have an ID
+ icon_state = "[initial(icon_state)]_card" // display it!
+ else // All else fails?
+ icon_state = "[initial(icon_state)]" // We're just a clock.
+
+/// Allows the timeclock to update its icon and lighting on power change, should power go out
+/obj/machinery/computer/timeclock/power_change()
+ var/old_stat = stat // Save our old stats for later
+ ..() // Call the parent proc to handle the actual powernet shit
+ if (old_stat != stat) // If our stat changed
+ update_icon() // update our icon
+ if (stat & NOPOWER) // If we no longer have power
+ set_light(0) // turn off our lights
+ else // Otherwise
+ set_light(brightness_on) // turn on our lights
+
+/// Handle clicking with an object
+/obj/machinery/computer/timeclock/attackby(obj/I, mob/user)
+ if (istype(I, /obj/item/card/id)) // If the user clicked with an ID in hand
+ if (!card && user.canUnEquip(I)) // Check if we already have an ID and that the user can drop the ID
+ I.forceMove(src) // Move the ID into our own location
+ card = I // Set our card to the ID
+ SStgui.update_uis(src) // Update all open UIs for us
+ update_icon() // Update our icon to reflect the new ID
+ else if (card) // There must've already been an ID inserted
+ to_chat(user, "There is already an ID card inside.")
+ return // Quit doing shit so we don't hit the timeclock
+ . = ..() // Set our return value to that of the parent proc
+
+/// Handle user clicking
+/obj/machinery/computer/timeclock/attack_hand(var/mob/user as mob)
+ if (..()) // If for some reason the parent proc returns true
+ return // We won't do anything
+ user.set_machine(src) // Otherwise, set the mob's machine to us
+ ui_interact(user) // Provide a UI to the user
+
+/// Handle UI interactions with this arcane shit known as tee gee yew eye
+/obj/machinery/computer/timeclock/ui_interact(mob/user, datum/tgui/ui)
+ ui = SStgui.try_update_ui(user, src, ui) // Attempt to update the UI
+ if (!ui) // If the UI doesn't exist
+ ui = new(user, src, "TimeClock", name) // Make a new one
+ ui.open() // And open it for the user
+
+/// Handle UI data
+/obj/machinery/computer/timeclock/ui_data(mob/user)
+ var/list/data = ..() // Call the parent object's data proc and assign that to the data list
+
+ // Data for showing user's own PTO
+ if (user.client) // If the client exists
+ data["department_hours"] = \
+ SANITIZE_LIST(user.client.department_hours) // Add the department hours list into data
+ data["user_name"] = "[user]" // Set the username to the user
+
+ // Data about the card we put into the timeclock
+ data["card"] = null // Add card data,
+ data["assignment"] = null // Assignment data,
+ data["job_datum"] = null // The job datum,
+ data["allow_change_job"] = null // Whether or not we can change jobs,
+ data["job_choices"] = null // And the possible jobs
+ if (card) // If we have a card
+ data["card"] = "[card]" // Set card data,
+ data["assignment"] = card.assignment // Assignment data,
+ var/datum/job/job = \
+ SSjob.GetJob(SSjob.get_job_name(card.assignment)) // Create a new job datum,
+ if (job) // If the job exists
+ data["job_datum"] = list( // Set job datum to a list with
+ "title" = job.title, // The job title,
+ "departments" = \
+ flags_to_english(job.department_flag, job.flag), // The job department,
+ "selection_color" = job.selection_color, // The selection color,
+ "timeoff_factor" = job.timeoff_factor, // The timeoff factor,
+ "pto_department" = job.pto_type // And the PTO type
+ )
+ if (CONFIG_GET(flag/time_off) \
+ && CONFIG_GET(flag/pto_job_change)) // If we allow timeoff and job changing
+ data["allow_change_job"] = TRUE // Add that to the data
+ if (job && job.timeoff_factor < 0) // They're off duty so we have to lookup available jobs
+ data["job_choices"] = \
+ get_open_on_duty_jobs(user, job.pto_type) // Set the job choices
+
+ return data // Give back the data list for subprocs
+
+/// The user interacted with me? owo!
+/obj/machinery/computer/timeclock/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state)
+ if (..()) // Check if the parent UI had anything to say
+ return TRUE // If so, we don't care to do anything
+
+ add_fingerprint(usr) // Add the user's fingerprint to the list so sec can find them
+
+ switch (action) // Check the action completed
+ if ("id") // If the ID slot was clicked
+ if (card) // Check if we have an ID already
+ usr.put_in_hands(card) // If so, give the user the card
+ card = null // And make sure we don't have it
+ else // Otherwise
+ var/obj/item/I = usr.get_active_held_item() // Get the item in their active hand
+ if (istype(I, /obj/item/card/id) && usr.canUnEquip(I)) // Check if it's an ID and they can unequip the ID
+ I.forceMove(src) // If so, move it into us
+ card = I // And make sure we know we have it
+ update_icon() // Update our icon too because we did something
+ return TRUE // And stop handling any UI in this run
+ if ("switch-to-on-duty-rank") // If they switched to on-duty
+ if (check_face()) // Check that their face is visible
+ if (check_card_cooldown()) // And their card isn't on cooldown
+ make_on_duty(params["switch-to-on-duty-rank"], \
+ params["switch-to-on-duty-assignment"]) // Finally make them on-duty with the requested rank+assignment
+ usr.put_in_hands(card) // Give them their new card
+ card = null // And get it out of us
+ update_icon() // Update our icon in case the card was removed
+ return TRUE // And stop handling UI in this run
+ if ("switch-to-off-duty") // If they switched to off-duty
+ if (check_face()) // Check that their face is visible
+ if (check_card_cooldown()) // And that their card isn't on cooldown
+ make_off_duty() // Finally make them off-duty
+ usr.put_in_hands(card) // Shit out their ID into their hand
+ card = null // And get rid of that card
+ update_icon() // Update our icon in case the card was removed
+ return TRUE // And stop handling any UI in this run
+
+/// Gets the open on-duty jobs available to a user in a specified department
+/obj/machinery/computer/timeclock/proc/get_open_on_duty_jobs(var/mob/user, var/department)
+ var/list/available_jobs = list() // Make a list of available jobs
+ for (var/datum/job/job in SSjob.occupations) // For all jobs in existing occupations
+ if (is_open_onduty_job(user, department, job)) // Check if the job is open and on-duty for a user and given department
+ available_jobs[job.title] = list(job.title) // If it is, add it to the list of available jobs
+ if (job.alt_titles) // If the job has alt-titles (Like Station Engineer->Mechanic)
+ for (var/alt_job in job.alt_titles) // Go through all the alt titles
+ if (alt_job != job.title) // And if it isn't the current job's title (Station Engineer's alt title of Station Engineer)
+ available_jobs[job.title] += alt_job // Add it to the list under that job title
+ return available_jobs // Return the newly-filled list of available jobs
+
+/// How does xenobio work again? Oh right, we're checking the user's face here to allow access and prevent ne'er-do-wells from using someone's PTO
+/obj/machinery/computer/timeclock/proc/check_face()
+ if (!card) // If no card is inserted (why are we checking their face?)
+ to_chat(usr, "No ID is inserted.")
+ return FALSE // Face check failed
+ var/mob/living/carbon/human/H = usr // Get the usr as a human data type
+ if (!(istype(H))) // If they somehow aren't a human??
+ to_chat(usr, "Invalid user detected. Access denied.")
+ return FALSE // Face check failed
+ else if ((H.wear_mask && (H.wear_mask.flags_inv & HIDEFACE)) \
+ || (H.head && (H.head.flags_inv & HIDEFACE))) // No, you can't hide your face
+ to_chat(usr, "Facial recognition scan failed due to physical obstruction. Access denied.")
+ return FALSE // Face check failed
+ else if (H.get_face_name() == "Unknown" \
+ || !(H.real_name == card.registered_name)) // If they're unknown or their real name isn't the name on the ID
+ to_chat(usr, "Facial recognition scan failed. Access denied.")
+ return FALSE // Face check failed
+ else // Otherwise
+ return TRUE // Face check success!
+
+/// Force users to wait 10 minutes between clocking in and out
+/obj/machinery/computer/timeclock/proc/check_card_cooldown()
+ if (!card) // If we don't have a card
+ return FALSE // We can't check cooldown, fail the check
+ var/time_left = 10 MINUTES - (world.time - card.last_job_switch) // Determine how much time is left before the next switch
+ if (time_left > 0) // If there's any time left at all
+ to_chat(usr, "You need to wait another [round((time_left / 10) / 60, 1)] minute\s before you can switch.")
+ return FALSE // Fail the check
+ return TRUE // Otherwise pass the check
+
+/// Makes the active card on-duty with a set rank and assignment
+/obj/machinery/computer/timeclock/proc/make_on_duty(var/new_rank, var/new_assignment)
+ var/datum/job/old_job = \
+ SSjob.GetJob(SSjob.get_job_name(card.assignment)) // Get their old job from the card
+ var/datum/job/new_job = SSjob.GetJob(new_rank) // And their new job from the rank
+
+ if (!old_job \
+ || !is_open_onduty_job(usr, old_job.pto_type, new_job)) // If there's no old job or it's not an open and on-duty job
+ return // Do nothing
+ if (new_assignment != new_job.title \
+ && !(new_assignment in new_job.alt_titles)) // If the new assignment isn't the new job's title or alt-title
+ return // Do nothing
+ if (new_job) // As long as there's a new job
+ card.access = new_job.get_access() // Set the card's access to the new job's access
+ card.assignment = new_assignment // And the card's assignment to the new assignment
+ card.name = text(\
+ "[card.registered_name]'s ID Card ([card.assignment])") // And change the card's name
+ GLOB.data_core.manifest_modify(card.registered_name, \
+ card.assignment) // Apply the changes to the crew manifest
+ card.last_job_switch = world.time // Set the last job switch on the card to the current world time
+ new_job.current_positions++ // Add one to the current positions for the new job
+ var/mob/living/carbon/human/H = usr // Get the caller as a human data type
+ H.mind.assigned_role = card.assignment // Set the assigned role of the caller's mind to the assignment on the card
+ if (GLOB.announcement_systems.len) // If there are any announcement systems
+ var/obj/machinery/announcement_system/announcer = \
+ pick(GLOB.announcement_systems) // Pick an announcer, any announcer!
+ announcer.announce("ONDUTY", card.registered_name, \
+ card.assignment, list()) // Make the announcement for now on-duty personnel
+
+/// Makes the active card off-duty
+/obj/machinery/computer/timeclock/proc/make_off_duty()
+ var/datum/job/found_job = \
+ SSjob.GetJob(SSjob.get_job_name(card.assignment)) // Get the current job from the inserted card
+ if (!found_job) // If the card somehow doesn't have a job
+ return // https://www.youtube.com/watch?v=2k0SmqbBIpQ
+ var/new_dept = found_job.pto_type || PTO_CIVILIAN // New department is either the department's PTO type or civilian PTO
+ var/datum/job/pto_job = null // Create a new PTO job
+ for (var/datum/job/job in SSjob.occupations) // For all jobs in the list of occupations
+ if (job.pto_type == new_dept \
+ && job.timeoff_factor < 0) // If the job is the department's PTO type and has a negative timeoff factor
+ pto_job = job // That's the new PTO job
+ break // And stop looking
+ if (pto_job) // If a PTO job was found
+ // Apparently we aren't using this? I don't fucking know
+ // var/old_title = card.assignment
+ card.access = pto_job.get_access() // Assign the PTO job's access to the ID
+ card.assignment = pto_job.title // And the PTO job's title to the ID
+ card.name = text(\
+ "[card.registered_name]'s ID Card ([card.assignment])") // Set the card's new name
+ GLOB.data_core.manifest_modify(card.registered_name, \
+ card.assignment) // And apply that change to the crew manifest
+ card.last_job_switch = world.time // Set the last job switch on the card to the world's current time
+ var/mob/living/carbon/human/H = usr // Get the caller as a human data type
+ H.mind.assigned_role = pto_job.title // Set their mind's assigned role to the PTO job's title
+ found_job.current_positions-- // Remove one from the found job's position count since they no longer have that job
+ if (GLOB.announcement_systems.len) // If there are any announcement systems
+ var/obj/machinery/announcement_system/announcer = \
+ pick(GLOB.announcement_systems) // Pick an announcement system, any announcement system!
+ announcer.announce("OFFDUTY", card.registered_name, \
+ card.assignment, list()) // Make an off-duty announcement with that system
+ return // I really don't know why this return exists we always return anyways lol
+
+/// Check if a job is open and on-duty for a given user and department
+/obj/machinery/computer/timeclock/proc/is_open_onduty_job(var/mob/user, var/department, var/datum/job/job)
+ return job \
+ && job.current_positions <= job.total_positions \
+ && !jobban_isbanned(user, SSjob.get_job_name(job.title)) \
+ && job.player_old_enough(user.client) \
+ && job.pto_type == department \
+ && job.timeoff_factor > 0 // I feel like this requires some HEAVY explanation so here we go.
+ // First we check if the job exists
+ // Then we check that there are enough open slots
+ // Then we check if the user is jobbanned or not and negate that because it'll return positive if they're banned
+ // Then we have to check that they have enough playtime to actually join as that job
+ // Then we have to check that the PTO type matches the department requested (can't join security with a PTO type of medical)
+ // Then we have to check that there's a timeoff value so you can actually accrue hours of PTO by playing as that role
+
+/// Convert a department and job flag to an english phrase
+/obj/machinery/computer/timeclock/proc/flags_to_english(var/department,var/flag)
+ if (department == ENGSEC) // If the department flag is engineering or security (or silicon apparently?)
+ switch (flag) // Switch based on the flag
+ if (CAPTAIN, HOS, WARDEN, CHIEF) // Captain, Head of Security, Warden or Chief Engineer
+ return "Command" // Are all command
+ if (DETECTIVE, OFFICER) // Detectives and Officers
+ return "Security" // Are all security
+ //if (BRIGDOC) // Brig Docs
+ //return "Medsec" // Are medical and science
+ if (LAMBENT) // Lambent
+ return "Lambent" // Are obviously Lambent. Dunno what you expected fam
+ if (ENGINEER, ATMOSTECH) // Engineers and Atmos Technicians
+ return "Engineering" // Are engineering
+ if (ROBOTICIST) // Roboticist is defined here but I think it was supposed to be with medsci? Just in case.
+ return "Science" // They're science
+ else // Technically this won't always be silicon
+ return "Silicon" // But we're listing your ass as silicon anyways. Cope.
+ else if (department == MEDSCI) // Otherwise, if the department flag is medical or science
+ switch (flag) // Switch based on the flag (again)
+ if (RD_JF, CMO_JF) // Research Director or Chief Medical Officer
+ return "Command" // Are both command
+ if (SCIENTIST, ROBOTICIST) // Scientists and Roboticists (this is where I think this was meant to be but it's still with ENGSEC just in case)
+ return "Science" // Are obviously science
+ if (CHEMIST, DOCTOR, VIROLOGIST) // Chemists, Doctors, Virologists and Paramedics
+ return "Medical" // Are medical
+ if (GENETICIST) // Geneticists
+ return "Medsci" // Are kind of a special, middle of the road medical and science case
+ else // If you're landing here you're fucked
+ return "What the fuck?" // So what the fuck?
+ else if (department == CIVILIAN) // Otherwise you must be civilian, right? Right???
+ switch (flag) // Once again a switch based on the flag
+ if (HOP, QUARTERMASTER) // Head of Personnel and Quartermaster
+ return "Command" // Are command
+ //if (PRISONER) // Prisoners
+ //return "Prisoner" // Are... prisoners? They shouldn't be able to PTO but if they manage to break out they can access a console so no fuck you here
+ if (CARGOTECH, MINER) // Cargo Technicians and Miners
+ return "Cargo" // Are cargonians
+ else // Otherwise
+ return "Civilian" // You must be a civilian. You could be an assisstant or a clown or a bartender, I don't really care.
+ else // New department combo? Wack.
+ return "What the fuck?" // what the fuck?
diff --git a/hyperstation/code/game/objects/items/cards_ids.dm b/hyperstation/code/game/objects/items/cards_ids.dm
new file mode 100644
index 000000000..8ea19ec93
--- /dev/null
+++ b/hyperstation/code/game/objects/items/cards_ids.dm
@@ -0,0 +1,2 @@
+/obj/item/card/id
+ var/last_job_switch /// Last job switch for card's owner
diff --git a/hyperstation/code/game/objects/items/circuitboards/computer_circuitboards.dm b/hyperstation/code/game/objects/items/circuitboards/computer_circuitboards.dm
new file mode 100644
index 000000000..c1a843992
--- /dev/null
+++ b/hyperstation/code/game/objects/items/circuitboards/computer_circuitboards.dm
@@ -0,0 +1,3 @@
+/obj/item/circuitboard/computer/timeclock
+ name = "Timeclock (Computer Board)"
+ build_path = /obj/machinery/computer/timeclock
diff --git a/hyperstation/code/modules/client/client_defines.dm b/hyperstation/code/modules/client/client_defines.dm
new file mode 100644
index 000000000..705945fac
--- /dev/null
+++ b/hyperstation/code/modules/client/client_defines.dm
@@ -0,0 +1,9 @@
+/**
+ * Additional client defines. I needed somewhere to put these so here it is!
+ */
+/client
+ //////////////////////////////////////
+ // Things that require the database //
+ //////////////////////////////////////
+ var/list/department_hours = list() // Track hours of leave accrued for each department
+ var/list/play_hours = list() // Tracks total playtime hours for each department
diff --git a/hyperstation/code/modules/jobs/job_types/_job.dm b/hyperstation/code/modules/jobs/job_types/_job.dm
new file mode 100644
index 000000000..9051523de
--- /dev/null
+++ b/hyperstation/code/modules/jobs/job_types/_job.dm
@@ -0,0 +1,3 @@
+/datum/job
+ var/timeoff_factor = 3
+ var/pto_type
diff --git a/hyperstation/icons/obj/machinery/timeclock.dmi b/hyperstation/icons/obj/machinery/timeclock.dmi
new file mode 100644
index 000000000..164cd2912
Binary files /dev/null and b/hyperstation/icons/obj/machinery/timeclock.dmi differ
diff --git a/tgstation.dme b/tgstation.dme
index cc9104606..bdf532648 100644
--- a/tgstation.dme
+++ b/tgstation.dme
@@ -3082,9 +3082,12 @@
#include "code\modules\zombie\items.dm"
#include "code\modules\zombie\organs.dm"
#include "hyperstation\code\__DEFINES\economy.dm"
+#include "hyperstation\code\__DEFINES\misc.dm"
#include "hyperstation\code\__DEFINES\traits.dm"
#include "hyperstation\code\__DEFINES\wendigo.dm"
+#include "hyperstation\code\controllers\configuration\entries\timeoff.dm"
#include "hyperstation\code\controllers\subsystem\economy.dm"
+#include "hyperstation\code\controllers\subsystem\job.dm"
#include "hyperstation\code\datums\actions.dm"
#include "hyperstation\code\datums\ert_hazard_cleanup.dm"
#include "hyperstation\code\datums\actions\sysuit.dm"
@@ -3099,11 +3102,15 @@
#include "hyperstation\code\datums\weather\lithium_aurora.dm"
#include "hyperstation\code\datums\weather\oxygen_rain.dm"
#include "hyperstation\code\game\area\Space_Station_13_areas.dm"
+#include "hyperstation\code\game\machinery\announcement_system.dm"
#include "hyperstation\code\game\machinery\lore_terminal.dm"
+#include "hyperstation\code\game\machinery\computer\timeclock.dm"
#include "hyperstation\code\game\objects\railings.dm"
#include "hyperstation\code\game\objects\effects\landmarks.dm"
+#include "hyperstation\code\game\objects\items\cards_ids.dm"
#include "hyperstation\code\game\objects\items\coconutcannon.dm"
#include "hyperstation\code\game\objects\items\cosmetics.dm"
+#include "hyperstation\code\game\objects\items\circuitboards\computer_circuitboards.dm"
#include "hyperstation\code\game\objects\items\storage\big_bag.dm"
#include "hyperstation\code\game\objects\structures\bench.dm"
#include "hyperstation\code\game\objects\structures\ghost_role_spawners.dm"
@@ -3147,6 +3154,7 @@
#include "hyperstation\code\modules\cargo\sweatshop\metal.dm"
#include "hyperstation\code\modules\cargo\sweatshop\sweatshop.dm"
#include "hyperstation\code\modules\cargo\sweatshop\wooden.dm"
+#include "hyperstation\code\modules\client\client_defines.dm"
#include "hyperstation\code\modules\client\loadout\glasses.dm"
#include "hyperstation\code\modules\client\loadout\tablet.dm"
#include "hyperstation\code\modules\clothing\clothing.dm"
@@ -3161,6 +3169,7 @@
#include "hyperstation\code\modules\events\crystalloid_entities.dm"
#include "hyperstation\code\modules\food_and_drinks\recipes\drinks_recipes.dm"
#include "hyperstation\code\modules\integrated_electronics\input.dm"
+#include "hyperstation\code\modules\jobs\job_types\_job.dm"
#include "hyperstation\code\modules\jobs\job_types\civilian.dm"
#include "hyperstation\code\modules\jobs\job_types\job_alt_titles.dm"
#include "hyperstation\code\modules\jobs\job_types\roleplay.dm"
diff --git a/tgui/packages/tgui/interfaces/TimeClock.js b/tgui/packages/tgui/interfaces/TimeClock.js
new file mode 100644
index 000000000..fa3a4ddef
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/TimeClock.js
@@ -0,0 +1,144 @@
+import { toFixed } from 'common/math';
+import { Fragment } from 'inferno';
+import { useBackend } from '../backend';
+import { Box, Button, Flex, Icon, LabeledList, ProgressBar, Section, NoticeBox } from '../components';
+import { Window } from '../layouts';
+import { RankIcon } from './common/RankIcon';
+
+export const TimeClock = (props, context) => {
+ const { act, data } = useBackend(context);
+
+ const {
+ department_hours,
+ user_name,
+ card,
+ assignment,
+ job_datum,
+ allow_change_job,
+ job_choices,
+ } = data;
+
+ return (
+
+
+
+
+ OOC Note: PTO acquired is account-wide and shared across all characters.
+ Info listed below is not IC information.
+
+
+
+ {Object.keys(department_hours).map(key => (
+ 6
+ ? "good"
+ : department_hours[key] > 1
+ ? "average"
+ : "bad"
+ }>
+ {toFixed(department_hours[key], 1)} {department_hours[key] === 1 ? "hour" : "hours"}
+
+ ))}
+
+
+
+
+
+
+
+
+ {!!job_datum && (
+
+
+
+
+
+
+
+
+
+
+
+ {job_datum.title}
+
+
+
+
+
+
+ {job_datum.departments}
+
+
+ {job_datum.economic_modifier}
+
+
+ {job_datum.timeoff_factor > 0 && (
+
+ Earns PTO - {job_datum.pto_department}
+
+ ) || job_datum.timeoff_factor < 0 && (
+
+ Requires PTO - {job_datum.pto_department}
+
+ ) || (
+
+ Neutral
+
+ )}
+
+
+ )}
+
+
+ {!!(allow_change_job && job_datum ** job_datum.timeoff_factor !== 0 && assignment !== "Dismissed") && (
+
+ {job_datum.timeoff_factor > 0 && (
+ department_hours[job_datum.pto_department] > 0 && (
+
+ ) || (
+
+ Warning: You do not have enough accrued time off to go off-duty.
+
+ )
+ ) || (
+ Object.keys(job_choices).length && Object.keys(job_choices).map(job => {
+ let alt_titles = job_choices[job];
+
+ return alt_titles.map(title => (
+
+ ));
+ }) || (
+
+ No Open Positions - See Head Of Personnel
+
+ )
+ )}
+
+ )}
+
+
+ );
+};
\ No newline at end of file
diff --git a/tgui/packages/tgui/interfaces/common/RankIcon.js b/tgui/packages/tgui/interfaces/common/RankIcon.js
new file mode 100644
index 000000000..bdb88742f
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/common/RankIcon.js
@@ -0,0 +1,119 @@
+import { Icon } from "../../components";
+
+const rank2icon = {
+ // Command
+ 'Colony Director': 'user-tie',
+ 'Site Manager': 'user-tie',
+ 'Overseer': 'user-tie',
+ 'Head of Personnel': 'briefcase',
+ 'Crew Resources Officer': 'briefcase',
+ 'Deputy Director': 'briefcase',
+ 'Command Secretary': 'user-tie',
+ // Security
+ 'Head of Security': 'user-shield',
+ 'Security Commander': 'user-shield',
+ 'Chief of Security': 'user-shield',
+ 'Warden': ['city', 'shield-alt'],
+ 'Detective': 'search',
+ 'Forensic Technician': 'search',
+ 'Security Officer': 'user-shield',
+ 'Junior Officer': 'user-shield',
+ // Engineering
+ 'Chief Engineer': 'toolbox',
+ 'Atmospheric Technician': 'wind',
+ 'Station Engineer': 'toolbox',
+ 'Maintenance Technician': 'wrench',
+ 'Engine Technician': 'toolbox',
+ 'Electrician': 'toolbox',
+ // Medical
+ 'Chief Medical Officer': 'user-md',
+ 'Chemist': 'mortar-pestle',
+ 'Pharmacist': 'mortar-pestle',
+ 'Medical Doctor': 'user-md',
+ 'Surgeon': 'user-md',
+ 'Emergency Physician': 'user-md',
+ 'Nurse': 'user-md',
+ 'Virologist': 'disease',
+ 'Paramedic': 'ambulance',
+ 'Emergency Medical Technician': 'ambulance',
+ 'Psychiatrist': 'couch',
+ 'Psychologist': 'couch',
+ // Science
+ 'Research Director': 'user-graduate',
+ 'Research Supervisor': 'user-graduate',
+ 'Roboticist': 'robot',
+ 'Biomechanical Engineer': ['wrench', 'heartbeat'],
+ 'Mechatronic Engineer': 'wrench',
+ 'Scientist': 'flask',
+ 'Xenoarchaeologist': 'flask',
+ 'Anomalist': 'flask',
+ 'Phoron Researcher': 'flask',
+ 'Circuit Designer': 'car-battery',
+ 'Xenobiologist': 'meteor',
+ 'Xenobotanist': ['biohazard', 'seedling'],
+ // Cargo
+ 'Quartermaster': 'box-open',
+ 'Supply Chief': 'warehouse',
+ 'Cargo Technician': 'box-open',
+ 'Shaft Miner': 'hard-hat',
+ 'Drill Technician': 'hard-hat',
+ // Exploration
+ 'Pathfinder': 'binoculars',
+ 'Explorer': 'user-astronaut',
+ 'Field Medic': ['user-md', 'user-astronaut'],
+ 'Pilot': 'space-shuttle',
+ // Civvies
+ 'Bartender': 'glass-martini',
+ 'Barista': 'coffee',
+ 'Botanist': 'leaf',
+ 'Gardener': 'leaf',
+ 'Chaplain': 'place-of-worship',
+ 'Counselor': 'couch',
+ 'Chef': 'utensils',
+ 'Cook': 'utensils',
+ 'Entertainer': 'smile-beam',
+ 'Performer': 'smile-beam',
+ 'Musician': 'guitar',
+ 'Stagehand': 'smile-beam',
+ // All of the interns
+ 'Intern': 'school',
+ 'Apprentice Engineer': ['school', 'wrench'],
+ 'Medical Intern': ['school', 'user-md'],
+ 'Lab Assistant': ['school', 'flask'],
+ 'Security Cadet': ['school', 'shield-alt'],
+ 'Jr. Cargo Tech': ['school', 'box'],
+ 'Jr. Explorer': ['school', 'user-astronaut'],
+ 'Server': ['school', 'utensils'],
+ // Back to civvies
+ 'Internal Affairs Agent': 'balance-scale',
+ 'Janitor': 'broom',
+ 'Custodian': 'broom',
+ 'Sanitation Technician': 'hand-sparkles',
+ 'Maid': 'broom',
+ 'Librarian': 'book',
+ 'Journalist': 'newspaper',
+ 'Writer': 'book',
+ 'Historian': 'chalkboard-teacher',
+ 'Professor': 'chalkboard-teacher',
+ 'Visitor': 'user',
+ // Special roles
+ 'Emergency Responder': 'fighter-jet',
+};
+
+export const RankIcon = (props, context) => {
+ const {
+ rank,
+ color = 'label',
+ } = props;
+
+ let rankObj = rank2icon[rank];
+ if (typeof rankObj === "string") {
+ return ;
+ } else if (Array.isArray(rankObj)) {
+ return rankObj.map(icon => (
+
+ ));
+ } else {
+ return ;
+ }
+};
\ No newline at end of file