diff --git a/aurorastation.dme b/aurorastation.dme
index ba084b4b7f8..0c917daa146 100644
--- a/aurorastation.dme
+++ b/aurorastation.dme
@@ -491,6 +491,11 @@
#include "code\datums\tgui_module.dm"
#include "code\datums\verb_callbacks.dm"
#include "code\datums\weakrefs.dm"
+#include "code\datums\case_dossier\case_evidence.dm"
+#include "code\datums\case_dossier\case_investigation.dm"
+#include "code\datums\case_dossier\case_paper.dm"
+#include "code\datums\case_dossier\case_person.dm"
+#include "code\datums\case_dossier\case_report.dm"
#include "code\datums\changelog\changelog.dm"
#include "code\datums\components\_component.dm"
#include "code\datums\components\_hivenetechoes.dm"
@@ -3127,6 +3132,7 @@
#include "code\modules\modular_computers\file_system\programs\research\ntmonitor.dm"
#include "code\modules\modular_computers\file_system\programs\research\robotics.dm"
#include "code\modules\modular_computers\file_system\programs\security\camera.dm"
+#include "code\modules\modular_computers\file_system\programs\security\casedossier.dm"
#include "code\modules\modular_computers\file_system\programs\security\digitalwarrant.dm"
#include "code\modules\modular_computers\file_system\programs\security\guntracker.dm"
#include "code\modules\modular_computers\file_system\programs\security\implant_tracker.dm"
diff --git a/code/__HELPERS/icons.dm b/code/__HELPERS/icons.dm
index 7d416d13f84..ce783e87533 100644
--- a/code/__HELPERS/icons.dm
+++ b/code/__HELPERS/icons.dm
@@ -683,12 +683,7 @@ world
addY1 = min(flatY1, layer_image.pixel_y + 1)
addY2 = max(flatY2, layer_image.pixel_y + add.Height())
- if (
- addX1 != flatX1 \
- && addX2 != flatX2 \
- && addY1 != flatY1 \
- && addY2 != flatY2 \
- )
+ if(addX1 != flatX1 || addX2 != flatX2 || addY1 != flatY1 || addY2 != flatY2)
// Resize the flattened icon so the new icon fits
flat.Crop(
addX1 - flatX1 + 1,
@@ -698,8 +693,8 @@ world
)
flatX1 = addX1
- flatX2 = addY1
- flatY1 = addX2
+ flatX2 = addX2
+ flatY1 = addY1
flatY2 = addY2
// Blend the overlay into the flattened icon
diff --git a/code/datums/case_dossier/case_evidence.dm b/code/datums/case_dossier/case_evidence.dm
new file mode 100644
index 00000000000..032d711f497
--- /dev/null
+++ b/code/datums/case_dossier/case_evidence.dm
@@ -0,0 +1,95 @@
+/datum/evidence_item
+ /// The id of the evidence item. Assigned automatically.
+ var/id = ""
+ /// Evidence name
+ var/label = ""
+ /// The category type a piece of evidence is, such as contraband
+ var/evidence_type = "Item"
+ /// The location the evidence was found
+ var/location = ""
+ /// The locker the evidence is in
+ var/evidence_locker = ""
+ /// The person to collect the evidence
+ var/collected_by = ""
+ /// The time the evidence was collected
+ var/collected_at = ""
+ /// The notes attached to the evidence
+ var/notes = ""
+ /// The people related to the evidence
+ var/list/linked_people = list()
+
+/datum/evidence_item/proc/tgui_data()
+ var/list/data = list()
+ data["id"] = id
+
+ data["label"] = label
+ data["type"] = evidence_type
+ data["location"] = location
+ data["evidence_locker"] = evidence_locker
+ data["linked_people"] = linked_people
+ data["collected_by"] = collected_by
+ data["collected_at"] = collected_at
+ data["notes"] = notes
+
+ data["title"] = label
+ data["author"] = collected_by
+ data["created_at"] = collected_at
+
+ return data
+
+/datum/evidence_item/New(var/id_input, var/label_input, var/evidence_type_input, var/collected_by_input, var/collected_at_input, var/collected_location_input)
+ ..()
+ id = "E-[id_input]"
+ label = label_input
+ evidence_type = evidence_type_input
+
+ collected_by = collected_by_input
+ collected_at = collected_at_input
+ location = collected_location_input
+
+/datum/evidence_item/Destroy(force)
+ linked_people.Cut()
+ return ..()
+
+/datum/evidence_item/photo
+ /// The photo id, used as part of ensuring the viewing client gets the proper photo
+ var/photo_id = ""
+ /// The image of the photo
+ var/icon/img
+ /// What is scribbled on the photo
+ var/scribble = ""
+ /// The associated evidence of the photo
+ var/list/linked_evidence = list()
+
+/datum/evidence_item/photo/New(id_input, i_label_input, evidence_type_input = "Photo", collected_by_input,
+collected_at_input, collected_location_input, var/photo_id_input, var/photo_img_input, var/photo_scribble_input)
+ ..()
+ id = "P-[id_input]"
+ photo_id = photo_id_input
+ img = photo_img_input
+ scribble = photo_scribble_input
+
+/datum/evidence_item/photo/Destroy(force)
+ linked_evidence.Cut()
+ return ..()
+
+/datum/evidence_item/photo/tgui_data()
+ var/list/data = list()
+ data["id"] = id
+ data["photo_id"] = photo_id
+ data["caption"] = label
+ data["type"] = evidence_type
+ data["location"] = location
+ data["taken_by"] = collected_by
+ data["taken_at"] = collected_at
+ data["collected_by"] = collected_by
+ data["collected_at"] = collected_at
+ data["notes"] = notes
+ data["scribble"] = scribble
+ data["linked_evidence"] = linked_evidence
+
+ if(img)
+ data["image"] = "tmp_photo_[photo_id || id].png"
+
+ return data
+
diff --git a/code/datums/case_dossier/case_investigation.dm b/code/datums/case_dossier/case_investigation.dm
new file mode 100644
index 00000000000..d823d73919b
--- /dev/null
+++ b/code/datums/case_dossier/case_investigation.dm
@@ -0,0 +1,576 @@
+#define STATUS_OPEN "Open"
+#define STATUS_SUBMITTED "Submitted"
+#define STATUS_ARCHIVED "Archived"
+/datum/investigation_case
+ /// The id of the case. Assigned automatically.
+ var/case_id = ""
+ /// The case name
+ var/title = "new case"
+ /// Case status, determining its current status and ability to be editted
+ var/status = "Open"
+ /// The investigators working on the case
+ var/investigator = ""
+ /// Tags associated with the case
+ var/list/tags = list()
+
+ /// Evidence associated with the case
+ var/list/evidence_refs = list()
+ /// Photos associated with the case
+ var/list/photo_refs = list()
+ /// Reports associated with the case
+ var/list/report_refs = list()
+
+ /// Case summary
+ var/summary = ""
+ /// Who started the case
+ var/created_by = ""
+ /// When the case was opened
+ var/created_at = ""
+ /// When the case was last updated/editted
+ var/updated_at = ""
+
+ /// Timeline description of the events of the case
+ var/timeline = ""
+ /// The findings of the case, such as motive
+ var/findings = ""
+
+ /// The victims in the case
+ var/list/victims = list()
+ /// The suspects in the case
+ var/list/suspects = list()
+ /// The witnesses in the case
+ var/list/witnesses = list()
+
+ /// Person IDs, set per case
+ var/person_uid = 1
+ /// Evidence ID, set per case
+ var/evidence_uid = 1
+ /// Photo ID, set per case
+ var/photo_uid = 1
+ /// Report ID, set per case
+ var/report_uid = 1
+
+/// The case ID, to be incremented per new case
+GLOBAL_VAR_INIT(case_dossier_uid, 1)
+/// All case dossiers
+GLOBAL_LIST_EMPTY(case_dossier_cases)
+/datum/investigation_case/New(var/datum/investigation_case/N, var/mob/creator)
+ ..()
+ case_id = "C-[worlddate2text()]-[GLOB.case_dossier_uid++]"
+
+ if(creator)
+ created_by = creator.name
+ investigator = creator.name
+ created_at = worldtime2text()
+ updated_at = created_at
+
+ if(!N)
+ return
+
+ title = N.title
+ status = STATUS_OPEN
+ investigator = N.investigator
+ tags = N.tags.Copy()
+
+ summary = N.summary
+ timeline = N.timeline
+ findings = N.findings
+
+ person_uid = N.person_uid
+ evidence_uid = N.evidence_uid
+ photo_uid = N.photo_uid
+ report_uid = N.report_uid
+
+ // Copy the people lists
+ victims = copy_person_list(N.victims)
+ suspects = copy_person_list(N.suspects)
+ witnesses = copy_person_list(N.witnesses)
+
+ // Copy the lists of evidence, photos, and reports
+ evidence_refs = copy_evidence_list(N.evidence_refs)
+ photo_refs = copy_photo_list(N.photo_refs)
+ report_refs = copy_report_list(N.report_refs)
+
+/// Makes a copy of the people lists, for case duplication
+/datum/investigation_case/proc/copy_person_list(var/list/source)
+ var/list/copied = list()
+
+ for(var/datum/investigation_person/P in source)
+ var/datum/investigation_person/New_P = new
+
+ New_P.id = P.id
+ New_P.name = P.name
+ New_P.role = P.role
+ New_P.notes = P.notes
+
+ copied += New_P
+
+ return copied
+
+/// Makes a copy of the evidence lists, for case duplication
+/datum/investigation_case/proc/copy_evidence_list(var/list/source)
+ var/list/copied = list()
+
+ for(var/datum/evidence_item/I in source)
+ var/datum/evidence_item/new_I = new
+
+ new_I.id = I.id
+ new_I.label = I.label
+ new_I.evidence_type = I.evidence_type
+ new_I.location = I.location
+ new_I.evidence_locker = I.evidence_locker
+ new_I.collected_by = I.collected_by
+ new_I.collected_at = I.collected_at
+ new_I.notes = I.notes
+ new_I.linked_people = I.linked_people.Copy()
+
+ copied += new_I
+
+ return copied
+
+/// Makes a copy of the photo lists, for case duplication
+/datum/investigation_case/proc/copy_photo_list(var/list/source)
+ var/list/copied = list()
+
+ for(var/datum/evidence_item/photo/P in source)
+ var/datum/evidence_item/photo/new_P = new
+
+ new_P.id = P.id
+ new_P.label = P.label
+ new_P.evidence_type = P.evidence_type
+ new_P.location = P.location
+ new_P.evidence_locker = P.evidence_locker
+ new_P.collected_by = P.collected_by
+ new_P.collected_at = P.collected_at
+ new_P.notes = P.notes
+ new_P.linked_people = P.linked_people.Copy()
+
+ new_P.photo_id = P.photo_id
+ new_P.img = P.img
+ new_P.scribble = P.scribble
+ new_P.linked_evidence = P.linked_evidence.Copy()
+
+ copied += new_P
+
+ return copied
+
+/// Makes a copy of the report lists, for case duplication
+/datum/investigation_case/proc/copy_report_list(var/list/source)
+ var/list/copied = list()
+
+ for(var/datum/case_dossier_report/R in source)
+ var/datum/case_dossier_report/new_R = new
+
+ new_R.id = R.id
+ new_R.title = R.title
+ new_R.evidence_type = R.evidence_type
+ new_R.author = R.author
+ new_R.created_at = R.created_at
+ new_R.collected_by = R.collected_by
+ new_R.collected_at = R.collected_at
+ new_R.scanned_by = R.scanned_by
+ new_R.scanned_at = R.scanned_at
+ new_R.content = R.content
+ new_R.notes = R.notes
+
+ copied += new_R
+
+ return copied
+
+/// Scans the evidence held in the active hand, into the case files
+/datum/investigation_case/proc/scan_held_evidence(var/mob/user, var/obj/item/E)
+ if(!E)
+ return FALSE
+ // Temporary storage of case labels, to use in case evidence bags are used
+ /// The area the evidence was collected in
+ var/collected_area
+ /// The time the evidence was collected
+ var/collected_time
+ /// Who collected the evidence
+ var/collected_by
+ /// The label on the evidence bag the evidence is in, if any
+ var/bag_label
+
+ // If it is an evidence bag, then we want what is inside of it
+ if(istype(E, /obj/item/evidencebag))
+ var/obj/item/evidencebag/B = E
+ E = B.stored_item
+ collected_area = B.collected_location
+ collected_time = B.collected_time
+ collected_by = B.collected_by
+ bag_label = B.label_text
+
+ if(!E)
+ to_chat(user, SPAN_NOTICE("The evidence bag is empty."))
+ return FALSE
+
+ if(istype(E, /obj/item/photo))
+ return FALSE
+
+ collected_by ||= user.name
+
+ /// The evidence item
+ var/datum/evidence_item/I = new /datum/evidence_item(evidence_uid++, bag_label || E.name, "Item", collected_by, collected_time, collected_area)
+
+
+ evidence_refs += I
+ touch()
+
+ return TRUE
+
+/datum/investigation_case/proc/scan_held_photo(var/mob/user, var/obj/item/E)
+ if(!E)
+ return FALSE
+
+ // Temporary storage of case labels, to use in case evidence bags are used
+ /// The area the evidence was collected in
+ var/collected_area
+ /// The time the evidence was collected
+ var/collected_time
+ /// Who collected the evidence
+ var/collected_by
+ /// The label on the evidence bag the evidence is in, if any
+ var/bag_label
+
+ // If it is an evidence bag, then we want what is inside of it, just like with regular evidence items
+ if(istype(E, /obj/item/evidencebag))
+ var/obj/item/evidencebag/B = E
+ E = B.stored_item
+ collected_area = B.collected_location
+ collected_time = B.collected_time
+ collected_by = B.collected_by
+ bag_label = B.label_text
+
+ if(!E)
+ to_chat(user, SPAN_NOTICE("The evidence bag is empty."))
+ return FALSE
+
+ if(!istype(E, /obj/item/photo))
+ return FALSE
+
+ /// The photo being copied
+ var/obj/item/photo/P = E
+ /// The evidence linked photo being stored
+ var/datum/evidence_item/photo/Photo = new(photo_uid++, bag_label || P.caption, "Photo", collected_by || P.taken_by,
+ collected_time || P.time_taken, collected_area || P.location_taken, P.id, P.img, P.scribble)
+
+ photo_refs += Photo
+ touch()
+
+ return TRUE
+
+/// Scan the new held paperwork to add to the case. Only takes singular pages
+/datum/investigation_case/proc/scan_held_report(var/mob/user, var/obj/item/held_item)
+ /// The scanned piece of paper
+ var/obj/item/paper/P = held_item
+
+ if(!istype(P, /obj/item/paper))
+ to_chat(user, SPAN_WARNING("You need to hold a piece of paper to scan it."))
+ return FALSE
+
+ /// The new paper item
+ var/datum/case_dossier_report/R = new(report_uid++, P.name, "Scanned paper", "Unknown", worldtime2text(), user?.name, worldtime2text(), user?.name)
+
+ var/rendered_content = ""
+
+ // Part 1: Deal with languages
+ if(P.info)
+ rendered_content += P.parse_languages(user, P.info, FALSE, TRUE)
+
+ // Part 2: Get the stamps
+ if(P.stamps)
+ rendered_content += P.stamps
+
+ // Part 3: Done
+ R.content = rendered_content
+
+ report_refs += R
+ touch()
+
+ to_chat(user, SPAN_NOTICE("You scan \the [P] into [title]."))
+ return TRUE
+
+/// Sets the fields to the specified values
+/datum/investigation_case/proc/set_field(var/field, var/value)
+ switch(field)
+ if("title")
+ title = value
+ if("status")
+ status = value
+ if("investigator")
+ investigator = value
+ if("summary")
+ summary = value
+ if("timeline")
+ timeline = value
+ if("findings")
+ findings = value
+
+ touch()
+
+/// Figures out and sets the tags
+/datum/investigation_case/proc/set_tags_from_text(var/value)
+ tags = list()
+
+ if(!value)
+ touch()
+ return
+
+ var/list/raw_tags = splittext(value, ",")
+ for(var/tag in raw_tags)
+ var/clean_tag = trim(tag)
+ if(clean_tag)
+ tags += clean_tag
+
+ touch()
+
+/// We updated it. Time to set the updated time.
+/datum/investigation_case/proc/touch()
+ updated_at = worldtime2text()
+
+/// Fetch the relevant people
+/datum/investigation_case/proc/get_person_list(var/list_name)
+ switch(list_name)
+ if("victims")
+ return victims
+ if("suspects")
+ return suspects
+ if("witnesses")
+ return witnesses
+
+ return
+
+/// Add a new person
+/datum/investigation_case/proc/add_person(var/list_name)
+ var/list/L = get_person_list(list_name)
+ if(!L)
+ return FALSE
+ var/role = "Unknown"
+ switch(list_name)
+ if("victims")
+ role = "Victim"
+ if("suspects")
+ role = "Suspect"
+ if("witnesses")
+ role = "Witness"
+
+ var/datum/investigation_person/P = new(person_uid++, role_input = role)
+
+
+ L += P
+ touch()
+ return TRUE
+
+/// Edit the values of a person
+/datum/investigation_case/proc/edit_person(var/list_name, var/id, var/field, var/value)
+ var/list/L = get_person_list(list_name)
+ if(!L)
+ return FALSE
+
+ for(var/datum/investigation_person/P in L)
+ if(P.id == id)
+ switch(field)
+ if("name")
+ P.name = value
+ if("role")
+ P.role = value
+ if("notes")
+ P.notes = value
+
+ touch()
+ return TRUE
+
+ return FALSE
+
+/// Alright removing a person
+/datum/investigation_case/proc/remove_person(var/list_name, var/id)
+ var/list/L = get_person_list(list_name)
+ if(!L)
+ return FALSE
+
+ for(var/datum/investigation_person/P in L)
+ if(P.id == id)
+ L -= P
+ qdel(P)
+ touch()
+ return TRUE
+
+ return FALSE
+
+/// Send the person data to TGUI
+/datum/investigation_case/proc/person_list_tgui_data(var/list/L)
+ var/list/out = list()
+
+ for(var/datum/investigation_person/P in L)
+ out += list(P.tgui_data())
+
+ return out
+
+/// Send the evidence data to TGUI
+/datum/investigation_case/proc/evidence_list_tgui_data(var/list/L)
+ var/list/out = list()
+
+ for(var/datum/evidence_item/I in L)
+ out += list(I.tgui_data())
+
+ return out
+
+/// Send the report data to TGUI
+/datum/investigation_case/proc/report_list_tgui_data(var/list/reports)
+ var/list/data = list()
+
+ if(!reports)
+ return data
+
+ for(var/datum/case_dossier_report/R in reports)
+ data += list(R.tgui_data())
+
+ return data
+
+/// Manually add evidence, instead of scanning it
+/datum/investigation_case/proc/add_manual_evidence(var/mob/user)
+ var/datum/evidence_item/I = new(evidence_uid++, "New Evidence", "Item", user?.name, worldtime2text())
+ evidence_refs += I
+ touch()
+ return TRUE
+
+/// Find a specific evidence based on ID
+/datum/investigation_case/proc/find_evidence_in_list(var/list/L, var/id)
+ for(var/datum/evidence_item/I in L)
+ if(I.id == id)
+ return I
+ return
+
+/// Edit an evidence item
+/datum/investigation_case/proc/edit_evidence_item(var/list/L, var/id, var/field, var/value)
+ var/datum/evidence_item/I = find_evidence_in_list(L, id)
+ if(!I)
+ return FALSE
+
+ switch(field)
+ if("label", "caption", "title")
+ I.label = value
+
+ if("type")
+ I.evidence_type = value
+
+ if("location")
+ I.location = value
+
+ if("evidence_locker")
+ I.evidence_locker = value
+
+ if("notes")
+ I.notes = value
+
+ if("collected_by", "taken_by", "author")
+ I.collected_by = value
+
+ if("collected_at", "taken_at", "created_at")
+ I.collected_at = value
+
+ if("linked_people")
+ I.linked_people = text_to_clean_list(value)
+
+ if("linked_evidence")
+ if(istype(I, /datum/evidence_item/photo))
+ var/datum/evidence_item/photo/P = I
+ P.linked_evidence = text_to_clean_list(value)
+
+ touch()
+ return TRUE
+
+/// Remove an evidence item
+/datum/investigation_case/proc/remove_evidence_item(var/list/L, var/id)
+ var/datum/evidence_item/I = find_evidence_in_list(L, id)
+ if(!I)
+ return FALSE
+
+ L -= I
+ qdel(I)
+ touch()
+ return TRUE
+
+/// Remove an report
+/datum/investigation_case/proc/remove_report_item(var/list/L, var/id)
+ var/datum/case_dossier_report/R = find_report_in_list(L, id)
+ if(!R)
+ return FALSE
+
+ L -= R
+ qdel(R)
+ touch()
+ return TRUE
+
+/// Find a specific report based on ID
+/datum/investigation_case/proc/find_report_in_list(var/list/L, var/id)
+ for(var/datum/case_dossier_report/I in L)
+ if(I.id == id)
+ return I
+ return
+
+/// Edit an evidence item
+/datum/investigation_case/proc/edit_evidence(var/id, var/field, var/value)
+ return edit_evidence_item(evidence_refs, id, field, value)
+
+/// Remove evidence
+/datum/investigation_case/proc/remove_evidence(var/id)
+ return remove_evidence_item(evidence_refs, id)
+
+/// Edit a photo
+/datum/investigation_case/proc/edit_photo(var/id, var/field, var/value)
+ return edit_evidence_item(photo_refs, id, field, value)
+
+/// Remove a photo
+/datum/investigation_case/proc/remove_photo(var/id)
+ return remove_evidence_item(photo_refs, id)
+
+/// remove a report
+/datum/investigation_case/proc/remove_report(var/id)
+ return remove_report_item(report_refs, id)
+
+/// Makes an identical case
+/datum/investigation_case/proc/copy_case()
+ var/datum/investigation_case/N = new(src)
+
+ return N
+
+/// creates a clean list from a set of text
+/datum/investigation_case/proc/text_to_clean_list(var/value)
+ var/list/out = list()
+
+ if(!value)
+ return out
+
+ var/list/raw_values = splittext(value, ",")
+ for(var/raw_value in raw_values)
+ var/clean_value = trim(raw_value)
+ if(clean_value)
+ out += clean_value
+
+ return out
+
+/// Send a photo to a viewing user so they can see it
+/datum/investigation_case/proc/send_photo_resources(var/mob/user)
+ if(!user)
+ return
+
+ for(var/datum/evidence_item/photo/P in photo_refs)
+ if(!P.img)
+ continue
+
+ send_rsc(user, P.img, "tmp_photo_[P.photo_id || P.id].png")
+
+/datum/investigation_case/Destroy(force)
+ // Photo might contain refs to evidence, both of which might reference people
+ QDEL_LIST(photo_refs)
+ QDEL_LIST(evidence_refs)
+ QDEL_LIST(report_refs)
+ QDEL_LIST(victims)
+ QDEL_LIST(witnesses)
+ QDEL_LIST(suspects)
+ ..()
+
+#undef STATUS_OPEN
+#undef STATUS_SUBMITTED
+#undef STATUS_ARCHIVED
diff --git a/code/datums/case_dossier/case_paper.dm b/code/datums/case_dossier/case_paper.dm
new file mode 100644
index 00000000000..c806e45ce56
--- /dev/null
+++ b/code/datums/case_dossier/case_paper.dm
@@ -0,0 +1,183 @@
+/datum/investigation_case/proc/paper_escape(var/value)
+ if(!value)
+ return "N/A"
+
+ var/text = trim("[value]")
+ if(!length(text))
+ return "N/A"
+
+ text = html_encode(text)
+
+ text = replacetext(text, "\[", "[")
+ text = replacetext(text, "\]", "]")
+
+ text = replacetext(text, ascii2text(13) + ascii2text(10), "\[br\]")
+ text = replacetext(text, ascii2text(13), "\[br\]")
+ text = replacetext(text, ascii2text(10), "\[br\]")
+
+ return text
+
+/datum/investigation_case/proc/paper_bullet(var/value)
+ return "\[*\] [paper_escape(value)]"
+
+/datum/investigation_case/proc/printable_person(var/datum/investigation_person/person, var/fallback_role)
+ var/list/parts = list()
+
+ parts += person.role || fallback_role
+ parts += person.name || "Unnamed"
+
+ if(person.notes)
+ parts += person.notes
+
+ return paper_bullet(jointext(parts, " - "))
+
+/datum/investigation_case/proc/printable_people()
+ var/list/out = list()
+
+ for(var/datum/investigation_person/person in victims)
+ out += printable_person(person, "Victim")
+
+ for(var/datum/investigation_person/person in suspects)
+ out += printable_person(person, "Suspect")
+
+ for(var/datum/investigation_person/person in witnesses)
+ out += printable_person(person, "Witness")
+
+ if(!length(out))
+ out += paper_bullet("None recorded")
+
+ return jointext(out, "\n")
+
+/datum/investigation_case/proc/printable_attached_documents()
+ var/list/out = list()
+
+ for(var/datum/case_dossier_report/report in report_refs)
+ out += paper_bullet("[report.id || "Unindexed"] - [report.title || "Untitled paperwork"]")
+
+ for(var/datum/evidence_item/photo/photo in photo_refs)
+ out += paper_bullet("[photo.id || "Unindexed"] - [photo.label || "Untitled photograph"]")
+
+ if(!length(out))
+ out += paper_bullet("None recorded")
+
+ return jointext(out, "\n")
+
+/datum/investigation_case/proc/printable_evidence_item(var/datum/evidence_item/item)
+ var/id = paper_escape(item.id || "Unindexed")
+ var/name = paper_escape(item.label || "Unnamed evidence")
+ var/type = paper_escape(item.evidence_type || "Unspecified")
+ var/location = paper_escape(item.location || "Unspecified")
+
+ return "\[*\] \[b\][id]\[/b\] - [name] - \[b\]Type:\[/b\] [type] - \[b\]Location:\[/b\] [location]"
+
+/datum/investigation_case/proc/printable_evidence()
+ var/list/out = list()
+
+ for(var/datum/evidence_item/item in evidence_refs)
+ out += printable_evidence_item(item)
+
+ for(var/datum/evidence_item/photo/photo in photo_refs)
+ out += printable_evidence_item(photo)
+
+ if(!length(out))
+ out += paper_bullet("None recorded")
+
+ return jointext(out, "\n")
+
+/datum/investigation_case/proc/print_case_paper(var/mob/user, var/paper_name, var/text)
+ if(!user)
+ return FALSE
+
+ var/obj/item/paper/P = new(get_turf(user))
+ P.set_content(paper_name, text, FALSE)
+ user.put_in_any_hand_if_possible(P)
+
+ to_chat(user, SPAN_NOTICE("You print [paper_name]."))
+ return TRUE
+
+/datum/investigation_case/proc/print_summary(var/mob/user)
+ var/list/narrative = list()
+ narrative += paper_bullet(summary || "No narrative recorded.")
+
+ if(timeline)
+ narrative += paper_bullet("Timeline:")
+ narrative += "\[list\]"
+ narrative += print_multiline_list(timeline)
+ narrative += "\[/list\]"
+
+ var/list/conclusion = list()
+ conclusion += paper_bullet(findings || "No findings recorded.")
+
+ var/list/notes = list()
+ notes += paper_bullet("Status: [status]")
+ notes += paper_bullet("Created by: [created_by || "Unknown"]")
+ notes += paper_bullet("Created at: [created_at || "Unknown"]")
+ notes += paper_bullet("Last updated: [updated_at || "Unknown"]")
+
+ if(length(tags))
+ notes += paper_bullet("Tags: [jointext(tags, ", ")]")
+
+ var/report = {"
+\[table\]\[cell\]\[hr\]\[small\]\[center\]\[logo_scc\]\[br\]\[b\]Stellar Corporate Conglomerate
+SCCV Horizon\[/b\]\[hr\]\[b\]Form 0205
+\[large\]Case Report\[/large\]\[/b\]\[/center\]\[hr\]\[b\]Date:\[/b\] \[date\]
+\[b\]Index:\[/b\] \[field\]
+
+\[b\]Case Number:\[/b\] [paper_escape(case_id)]
+\[b\]Attached Documents:\[/b\]\[list\]
+[printable_attached_documents()]
+\[/list\]\[hr\]\[b\]Involved Personnel:\[/b\]\[list\]
+[printable_people()]
+\[/list\]\[b\]Narrative:\[/b\]\[list\]
+[jointext(narrative, "\n")]
+\[/list\]\[b\]Conclusion:\[/b\]\[list\]
+[jointext(conclusion, "\n")]
+\[/list\]\[b\]Additional Notes:\[/b\]\[list\]
+[jointext(notes, "\n")]
+\[/list\]\[hr\]\[b\]Time Printed:\[/b\] [paper_escape(worldtime2text())]
+\[b\]Investigative Personnel:\[/b\] [paper_escape(investigator || user.real_name)]
+\[b\]Relevant Signatures:\[/b\]
+\[field\]\[hr\]\[/table\]"}
+
+ return print_case_paper(user, "SCCF-0205 - Case Report ([case_id])", report)
+
+/datum/investigation_case/proc/print_evidence_log(var/mob/user)
+ var/manifest = {"
+\[table\]\[cell\]\[hr\]\[small\]\[center\]\[logo_scc\]\[br\]\[b\]Stellar Corporate Conglomerate
+SCCV Horizon\[/b\]\[hr\]\[b\]Form 0211
+\[large\]Evidence Manifest\[/large\]\[/b\]\[/center\]\[hr\]\[b\]Date:\[/b\] \[date\]
+\[b\]Index:\[/b\] \[field\]
+
+\[b\]Case Number:\[/b\] [paper_escape(case_id)]
+\[b\]Attached Documents:\[/b\]\[list\]
+[printable_attached_documents()]
+\[/list\]\[hr\]\[b\]Evidence:\[/b\]\[list\]
+[printable_evidence()]
+\[/list\]\[hr\]\[b\]Investigative Personnel:\[/b\] [paper_escape(investigator || user.real_name)]
+\[b\]Relevant Signatures:\[/b\] \[field\]\[hr\]\[/table\]"}
+
+ return print_case_paper(user, "SCCF-0211 - Evidence Manifest ([case_id])", manifest)
+
+/datum/investigation_case/proc/print_multiline_list(var/value, var/fallback = "None recorded")
+ if(!value)
+ return paper_bullet(fallback)
+
+ var/text = trim("[value]")
+ if(!length(text))
+ return paper_bullet(fallback)
+
+ var/list/out = list()
+ var/list/lines = splittext(text, ascii2text(10))
+
+ for(var/line in lines)
+ line = trim(line)
+
+ if(!length(line))
+ continue
+
+ out += paper_bullet(line)
+
+ if(!length(out))
+ return paper_bullet(fallback)
+
+ return jointext(out, "\n")
diff --git a/code/datums/case_dossier/case_person.dm b/code/datums/case_dossier/case_person.dm
new file mode 100644
index 00000000000..e425525865e
--- /dev/null
+++ b/code/datums/case_dossier/case_person.dm
@@ -0,0 +1,25 @@
+/datum/investigation_person
+ /// The person's id
+ var/id = ""
+ /// The person's name
+ var/name = ""
+ /// The person's role
+ var/role = ""
+ /// Notes about the person
+ var/notes = ""
+
+/datum/investigation_person/New(var/id_input, var/name_input, var/role_input, var/note_input)
+ ..()
+ id = "P-[id_input]"
+ name = name_input
+ role = role_input
+ notes = note_input
+
+/datum/investigation_person/proc/tgui_data()
+ var/list/data = list()
+ data["id"] = id
+ data["name"] = name
+ data["role"] = role
+ data["notes"] = notes
+ return data
+
diff --git a/code/datums/case_dossier/case_report.dm b/code/datums/case_dossier/case_report.dm
new file mode 100644
index 00000000000..9bc1dd63335
--- /dev/null
+++ b/code/datums/case_dossier/case_report.dm
@@ -0,0 +1,53 @@
+/datum/case_dossier_report
+ /// The id associated with the report
+ var/id
+ /// The report's title
+ var/title = "Untitled paperwork"
+ /// The report type.
+ var/evidence_type = "Scanned paper"
+ /// The author, mainly for future proofing
+ var/author
+ /// was created, for future proofing
+ var/created_at
+ /// Who collected it, for future proofing
+ var/collected_by
+ /// When it was collected, for future proofing
+ var/collected_at
+ /// Who scanned it
+ var/scanned_by
+ /// When it was scanned
+ var/scanned_at
+ /// What is written
+ var/content
+ /// Notes, for future proofing
+ var/notes
+
+/datum/case_dossier_report/New(var/id_input, var/title_input, var/evidence_type_input = "Scanned paper", var/author_input = "Unknown", var/created_input = worldtime2text(), var/collected_by_input = "Unknown", var/collected_at_input = worldtime2text(), var/scanned_by_input = "Unknown", var/scanned_at_input)
+ ..()
+ id = "R-[id_input]"
+ title = sanitize(title_input, MAX_NAME_LEN)
+ evidence_type = evidence_type_input
+ author = author_input
+ created_at = created_input
+ collected_by = collected_by_input
+ collected_at = collected_at_input
+ scanned_by = scanned_by_input
+
+ scanned_at = scanned_at_input ? scanned_at_input : collected_at
+
+/datum/case_dossier_report/proc/tgui_data()
+ var/list/data = list()
+
+ data["id"] = "[id]"
+ data["title"] = title
+ data["type"] = evidence_type
+ data["author"] = author
+ data["created_at"] = created_at
+ data["collected_by"] = collected_by
+ data["collected_at"] = collected_at
+ data["scanned_by"] = scanned_by
+ data["scanned_at"] = scanned_at
+ data["content"] = content
+ data["notes"] = notes
+
+ return data
diff --git a/code/datums/records.dm b/code/datums/records.dm
index b74e06ceba1..852d61a2dd8 100644
--- a/code/datums/records.dm
+++ b/code/datums/records.dm
@@ -274,3 +274,4 @@ GLOBAL_VAR_INIT(shuttle_uid, 0)
/datum/record/shuttle_assignment/New(var/for_shuttle)
. = ..()
shuttle = for_shuttle
+
diff --git a/code/datums/skills/occupational/medical.dm b/code/datums/skills/occupational/medical.dm
index 2b2010142bb..908748f6aa8 100644
--- a/code/datums/skills/occupational/medical.dm
+++ b/code/datums/skills/occupational/medical.dm
@@ -104,9 +104,22 @@
/singleton/skill/forensics
name = "Forensics"
- description = "Not currently implemented."
+ description = "Governs your knowledge of evidence gathering and analyzing."
maximum_level = SKILL_LEVEL_PROFESSIONAL
uneducated_skill_cap = SKILL_LEVEL_TRAINED
category = /singleton/skill_category/occupational
subcategory = SKILL_SUBCATEGORY_MEDICAL
component_type = FORENSICS_SKILL_COMPONENT
+ skill_level_descriptions = alist(
+ SKILL_LEVEL_UNFAMILIAR = "You have zero training or knowledge of forensic principles.",
+ SKILL_LEVEL_FAMILIAR = "You have minimal training on the basics of forensics and evidence preservation.
" \
+ + "You can do the following:
" \
+ + " - Automatically record time, place and gathering person on evidence bags and photos.",
+ SKILL_LEVEL_TRAINED = "You have years of formal training and experience with forensic principles.
" \
+ + "You can do the following:
" \
+ + " - Automatically record time, place and gathering person on evidence bags and photos.",
+ SKILL_LEVEL_PROFESSIONAL = "You have extensive training and experience with forensics.
" \
+ + "You can do the following:
" \
+ + " - Automatically record time, place and gathering person on evidence bags and photos.",
+ )
+ required = TRUE
diff --git a/code/modules/detectivework/tools/evidencebag.dm b/code/modules/detectivework/tools/evidencebag.dm
index 4fd9d5f6bdd..c9efa430363 100644
--- a/code/modules/detectivework/tools/evidencebag.dm
+++ b/code/modules/detectivework/tools/evidencebag.dm
@@ -7,8 +7,16 @@
icon_state = "evidenceobj"
item_state = ""
w_class = WEIGHT_CLASS_SMALL
- var/obj/item/stored_item = null
+ /// The item stored in the evidence bag
+ var/obj/item/stored_item
+ /// The label on the evidence bag
var/label_text = ""
+ /// Who collected the item
+ var/collected_by = ""
+ /// Where the item was collected
+ var/collected_location = ""
+ /// WHen the item was collected
+ var/collected_time = ""
/obj/item/evidencebag/mechanics_hints(mob/user, distance, is_adjacent)
. += ..()
@@ -65,9 +73,9 @@
human_user.visible_message("[human_user] puts \the [I] into \the [src].", SPAN_NOTICE("You put \the [I] inside \the [src]."),\
"You hear a rustle as someone puts something into a plastic bag.")
- store_item(I)
+ store_item(I, user)
-/obj/item/evidencebag/proc/store_item(obj/item/I)
+/obj/item/evidencebag/proc/store_item(obj/item/I, mob/user)
icon_state = "evidence"
var/mutable_appearance/MA = new(I)
MA.pixel_x = 0
@@ -75,6 +83,17 @@
MA.layer = FLOAT_LAYER
AddOverlays(list(MA, "evidence"))
+ var/forensic = GET_SKILL_LEVEL(user, FORENSICS_SKILL_COMPONENT)
+ forensic = forensic ? forensic : SKILL_LEVEL_TRAINED
+
+ // Trained people would know to add this data
+ if(forensic >= SKILL_LEVEL_FAMILIAR)
+ collected_location = get_area_display_name(get_area(I))
+ collected_by = user.name
+ collected_time = worldtime2text()
+ // And untrained people risk contaminating it
+ else if (prob(25))
+ I.add_fingerprint(user)
desc = "A plastic bag containing [I]."
I.forceMove(src)
stored_item = I
@@ -99,21 +118,60 @@
icon_state = "evidenceobj"
return
+/obj/item/evidencebag/feedback_hints(mob/user, distance, is_adjacent)
+ . = ..()
+ if(label_text)
+ . += SPAN_NOTICE("It is labelled: \"[label_text]\".")
+
+ if(collected_by)
+ . += SPAN_NOTICE("Collected by: [collected_by].")
+
+ if(collected_location)
+ . += SPAN_NOTICE("Collected from: [collected_location].")
+
+ if(collected_time)
+ . += SPAN_NOTICE("Collected at: [collected_time].")
+
/obj/item/evidencebag/examine(mob/user, distance, is_adjacent, infix, suffix, show_extended)
. = ..()
- if (stored_item)
+ if(stored_item)
examinate(user, stored_item, show_extended)
/obj/item/evidencebag/attackby(obj/item/attacking_item, mob/user)
if(attacking_item.tool_behaviour == TOOL_PEN || istype(attacking_item, /obj/item/flashlight/pen))
- var/tmp_label = sanitizeSafe( tgui_input_text(user, "Enter a label for [name]", "Label", label_text, MAX_NAME_LEN), MAX_NAME_LEN )
- if(length(tmp_label) > MAX_NAME_LEN)
- to_chat(user, SPAN_NOTICE("The label can be at most [MAX_NAME_LEN] characters long."))
+ var/static/list/evidence_label_fields = list(
+ "Label text",
+ "Collected by",
+ "Collected location",
+ "Collected time"
+ )
+
+ var/field = tgui_input_list(user, "Which evidence label field do you want to edit?", "Evidence Label", evidence_label_fields)
+ if(!field)
+ return
+
+ var/current_value = get_evidence_label_value(field)
+
+ var/new_value = tgui_input_text(
+ user,
+ "Enter [field] for [name]",
+ "Evidence Label",
+ current_value,
+ MAX_NAME_LEN
+ )
+
+ if(isnull(new_value))
+ return
+
+ set_evidence_label_value(field, new_value)
+
+ if(new_value == "")
+ to_chat(user, SPAN_NOTICE("You clear [field]."))
else
- to_chat(user, SPAN_NOTICE("You set the label to \"[tmp_label]\"."))
- label_text = tmp_label
- update_name_label()
+ to_chat(user, SPAN_NOTICE("You set [field] to \"[new_value]\"."))
+
return
+
. = ..()
/obj/item/evidencebag/proc/update_name_label(var/base_name = initial(name))
@@ -122,3 +180,34 @@
name = base_name
else
name = "[base_name] ([label_text])"
+
+/obj/item/evidencebag/proc/get_evidence_label_value(var/field)
+ switch(field)
+ if("Label text")
+ return label_text
+ if("Collected by")
+ return collected_by
+ if("Collected location")
+ return collected_location
+ if("Collected time")
+ return collected_time
+
+ return ""
+
+/obj/item/evidencebag/proc/set_evidence_label_value(var/field, var/value)
+ switch(field)
+ if("Label text")
+ label_text = value
+ update_name_label()
+ return TRUE
+ if("Collected by")
+ collected_by = value
+ return TRUE
+ if("Collected location")
+ collected_location = value
+ return TRUE
+ if("Collected time")
+ collected_time = value
+ return TRUE
+
+ return FALSE
diff --git a/code/modules/detectivework/tools/sample_kits.dm b/code/modules/detectivework/tools/sample_kits.dm
index 560f89c0445..ec4626eb99c 100644
--- a/code/modules/detectivework/tools/sample_kits.dm
+++ b/code/modules/detectivework/tools/sample_kits.dm
@@ -175,6 +175,7 @@
/obj/item/forensics/sample_kit/mechanics_hints(mob/user, distance, is_adjacent)
. += ..()
. += "Click drag it onto an object to collect fiber evidence. Alternatively, click on an object with non-Help intent."
+ . += "Click an evidence bag to collect evidence from the object within it. Alternatively, using harm intent checks the evidence bag."
/obj/item/forensics/sample_kit/proc/can_take_sample(var/mob/user, var/atom/supplied)
return (supplied.suit_fibers && supplied.suit_fibers.len)
@@ -187,6 +188,13 @@
if(!proximity)
return
add_fingerprint(user)
+
+ // We can sample items from within an evidence bag
+ if(user.a_intent != I_HURT && istype(A, /obj/item/evidencebag))
+ var/obj/item/evidencebag/bag = A
+ if(bag.stored_item)
+ A = bag.stored_item
+
if(can_take_sample(user, A))
take_sample(user,A)
return 1
diff --git a/code/modules/modular_computers/file_system/programs/app_presets_.dm b/code/modules/modular_computers/file_system/programs/app_presets_.dm
index 63cac179b97..bc158b9cb32 100644
--- a/code/modules/modular_computers/file_system/programs/app_presets_.dm
+++ b/code/modules/modular_computers/file_system/programs/app_presets_.dm
@@ -78,5 +78,6 @@ ABSTRACT_TYPE(/datum/modular_computer_app_presets)
/datum/computer_file/program/camera_monitor,\
/datum/computer_file/program/digitalwarrant,\
/datum/computer_file/program/records/security,\
+ /datum/computer_file/program/case_dossier,\
/datum/computer_file/program/guntracker,\
)
diff --git a/code/modules/modular_computers/file_system/programs/security/casedossier.dm b/code/modules/modular_computers/file_system/programs/security/casedossier.dm
new file mode 100644
index 00000000000..801fc5f106a
--- /dev/null
+++ b/code/modules/modular_computers/file_system/programs/security/casedossier.dm
@@ -0,0 +1,299 @@
+#define STATUS_OPEN "Open"
+#define STATUS_SUBMITTED "Submitted"
+#define STATUS_ARCHIVED "Archived"
+/datum/computer_file/program/case_dossier
+ filename = "case_dossier"
+ filedesc = "Case Dossier"
+ extended_desc = "Official NTsec program for the handling of investigation cases."
+ program_icon_state = "security_record"
+ program_key_icon_state = "yellow_key"
+ color = LIGHT_COLOR_RED
+ size = 6
+ requires_ntnet = TRUE
+ available_on_ntnet = TRUE
+ // Security officers and wardens are allowed to download the program, but not edit it
+ required_access_download = ACCESS_SECURITY
+ required_access_run = ACCESS_SECURITY
+ usage_flags = PROGRAM_ALL_REGULAR | PROGRAM_STATIONBOUND
+ tgui_id = "Casedossier"
+
+ var/list/stored_cases
+ var/datum/investigation_case/open_case
+
+/datum/computer_file/program/case_dossier/New(obj/item/modular_computer/comp)
+ . = ..()
+ stored_cases = GLOB.case_dossier_cases
+
+/datum/computer_file/program/case_dossier/Destroy()
+ stored_cases.Cut()
+ open_case = null
+ return ..()
+
+/// Who is permitted to edit the investigation files. Defaults to forensic staff
+/datum/computer_file/program/case_dossier/proc/can_edit(mob/user)
+ if(!user)
+ return FALSE
+
+ var/obj/item/card/id/I = user.GetIdCard()
+ if(!I)
+ return FALSE
+
+ return (ACCESS_FORENSICS_LOCKERS in I.access)
+
+/datum/computer_file/program/case_dossier/ui_data(mob/user)
+ var/list/data = list()
+
+ data["available_statuses"] = list(STATUS_OPEN, STATUS_SUBMITTED, STATUS_ARCHIVED)
+ data["can_edit"] = can_edit(user)
+ data["open_case"] = null
+ data["cases"] = list()
+
+ if(open_case)
+ open_case.send_photo_resources(user)
+ data["open_case"] = open_case.tgui_data()
+
+ if(stored_cases)
+ for(var/datum/investigation_case/C in stored_cases)
+ data["cases"] += list(C.tgui_data(TRUE))
+
+ return data
+
+/datum/investigation_case/proc/tgui_data(var/include_contents = TRUE)
+ var/list/data = list()
+
+ data["id"] = "[case_id]"
+ data["title"] = title
+ data["status"] = status
+ data["investigator"] = investigator
+ data["created_at"] = created_at
+ data["updated_at"] = updated_at
+ data["tags"] = tags
+
+ data["victim_count"] = length(victims)
+ data["suspect_count"] = length(suspects)
+ data["witness_count"] = length(witnesses)
+ data["evidence_count"] = length(evidence_refs)
+ data["photo_count"] = length(photo_refs)
+ data["report_count"] = length(report_refs)
+
+ if(include_contents)
+ data["summary"] = summary
+ data["timeline"] = timeline
+ data["findings"] = findings
+
+ data["victims"] = person_list_tgui_data(victims)
+ data["suspects"] = person_list_tgui_data(suspects)
+ data["witnesses"] = person_list_tgui_data(witnesses)
+
+ data["evidence"] = evidence_list_tgui_data(evidence_refs)
+ data["photos"] = evidence_list_tgui_data(photo_refs)
+ data["reports"] = report_list_tgui_data(report_refs)
+
+ return data
+
+/datum/computer_file/program/case_dossier/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state)
+ . = ..()
+ if(.)
+ return
+
+ switch(action)
+ if("new_case")
+ create_new_case(usr)
+ return TRUE
+
+ if("open_case")
+ open_case_by_id(params["id"])
+ return TRUE
+
+ if("save_case")
+ save_open_case(usr)
+ return TRUE
+
+ if("edit_case_field")
+ if(open_case)
+ open_case.set_field(params["field"], params["value"])
+ else
+ to_chat(usr, SPAN_NOTICE("No open case selected."))
+ return TRUE
+
+ if("edit_case_tags")
+ if(open_case)
+ open_case.set_tags_from_text(params["value"])
+ else
+ to_chat(usr, SPAN_NOTICE("No open case selected."))
+ return TRUE
+
+ if("add_person")
+ if(open_case)
+ open_case.add_person(params["list"])
+ else
+ to_chat(usr, SPAN_NOTICE("No open case selected."))
+ return TRUE
+
+ if("edit_person")
+ if(open_case)
+ open_case.edit_person(params["list"], params["id"], params["field"], params["value"])
+ else
+ to_chat(usr, SPAN_NOTICE("No open case selected."))
+ return TRUE
+
+ if("remove_person")
+ if(open_case)
+ open_case.remove_person(params["list"], params["id"])
+ else
+ to_chat(usr, SPAN_NOTICE("No open case selected."))
+ return TRUE
+
+ if("scan_evidence")
+ if(open_case)
+ open_case.scan_held_evidence(usr, usr.get_type_in_hands(/obj/item))
+ else
+ to_chat(usr, SPAN_NOTICE("No open case selected."))
+ return TRUE
+
+ if("scan_photo")
+ if(open_case)
+ open_case.scan_held_photo(usr, usr.get_type_in_hands(/obj/item))
+ else
+ to_chat(usr, SPAN_NOTICE("No open case selected."))
+ return TRUE
+
+ if("scan_report")
+ if(open_case)
+ open_case.scan_held_report(usr, usr.get_type_in_hands(/obj/item))
+ else
+ to_chat(usr, SPAN_NOTICE("No open case selected."))
+ return TRUE
+
+ if("print_summary")
+ if(open_case)
+ open_case.print_summary(usr)
+ else
+ to_chat(usr, SPAN_NOTICE("No open case selected."))
+ return TRUE
+
+ if("print_evidence_log")
+ if(open_case)
+ open_case.print_evidence_log(usr)
+ else
+ to_chat(usr, SPAN_NOTICE("No open case selected."))
+ return TRUE
+
+ if("set_case_status")
+ var/datum/investigation_case/C = find_case_by_id(params["id"])
+ if(C)
+ C.set_field("status", params["status"])
+ return TRUE
+
+ if("duplicate_case")
+ duplicate_case_by_id(params["id"], usr)
+ return TRUE
+
+ if("delete_case")
+ delete_case_by_id(params["id"])
+ return TRUE
+
+ if("add_manual_evidence")
+ if(open_case)
+ open_case.add_manual_evidence(usr)
+ return TRUE
+
+ if("edit_evidence")
+ if(open_case)
+ open_case.edit_evidence(params["id"], params["field"], params["value"])
+ return TRUE
+
+ if("remove_evidence")
+ if(open_case)
+ open_case.remove_evidence(params["id"])
+ return TRUE
+
+ if("edit_photo")
+ if(open_case)
+ open_case.edit_photo(params["id"], params["field"], params["value"])
+ return TRUE
+
+ if("remove_photo")
+ if(open_case)
+ open_case.remove_photo(params["id"])
+ return TRUE
+
+ if("remove_report")
+ if(open_case)
+ open_case.remove_report(params["id"])
+ return TRUE
+
+/// Starts a new case
+/datum/computer_file/program/case_dossier/proc/create_new_case(var/mob/user)
+ var/datum/investigation_case/C = new /datum/investigation_case(creator = user)
+
+ stored_cases += C
+ open_case = C
+
+/// Finds a new case by its id
+/datum/computer_file/program/case_dossier/proc/find_case_by_id(var/id)
+ if(!id)
+ return null
+
+ for(var/datum/investigation_case/C in stored_cases)
+ if("[C.case_id]" == "[id]")
+ return C
+
+ return null
+
+/// Opens a case by its id
+/datum/computer_file/program/case_dossier/proc/open_case_by_id(var/id)
+ var/datum/investigation_case/C = find_case_by_id(id)
+ if(!C)
+ return FALSE
+
+ open_case = C
+ return TRUE
+
+/// Saves the open case
+/// Right now it doesn't do much, as the program auto-saves.
+/datum/computer_file/program/case_dossier/proc/save_open_case(var/mob/user)
+ if(!open_case)
+ return FALSE
+
+ open_case.touch()
+
+ if(user)
+ to_chat(user, SPAN_NOTICE("You save [open_case.title]."))
+
+ return TRUE
+
+/datum/computer_file/program/case_dossier/proc/delete_case_by_id(var/id)
+ var/datum/investigation_case/C = find_case_by_id(id)
+ if(!C)
+ return FALSE
+
+ stored_cases -= C
+
+ if(open_case == C)
+ open_case = null
+
+ qdel(C)
+ return TRUE
+
+/// Duplicates the case based on the id
+/datum/computer_file/program/case_dossier/proc/duplicate_case_by_id(var/id, var/mob/user)
+ var/datum/investigation_case/C = find_case_by_id(id)
+ if(!C)
+ return FALSE
+
+ var/datum/investigation_case/N = C.copy_case()
+
+ N.title = "Copy of [C.title]"
+ N.created_by = user?.name
+ N.created_at = worldtime2text()
+ N.updated_at = N.created_at
+
+ stored_cases += N
+ open_case = N
+
+ return TRUE
+
+#undef STATUS_OPEN
+#undef STATUS_SUBMITTED
+#undef STATUS_ARCHIVED
diff --git a/code/modules/paperwork/photography.dm b/code/modules/paperwork/photography.dm
index 921268a4d5e..7e3694eb47f 100644
--- a/code/modules/paperwork/photography.dm
+++ b/code/modules/paperwork/photography.dm
@@ -26,7 +26,7 @@
/********
* photo *
********/
-GLOBAL_VAR_INIT(photo_count, 0)
+GLOBAL_VAR_INIT(photo_count, 1)
/obj/item/photo
name = "photo"
@@ -35,12 +35,26 @@ GLOBAL_VAR_INIT(photo_count, 0)
icon_state = "photo"
item_state = "paper"
w_class = WEIGHT_CLASS_TINY
- var/picture_desc // Who and/or what's in the picture.
+ /// Who and/or what's in the picture.
+ var/picture_desc
+ /// The photo's id
var/id
- var/icon/img //Big photo image
- var/scribble //Scribble on the back.
+ /// Big photo image
+ var/icon/img
+ /// Scribble on the back.
+ var/scribble
+ /// The small size of the photo used for the icon
var/icon/tiny
+ /// The size of the photo
var/photo_size = 3
+ /// The time the photo was taken
+ var/time_taken
+ /// The place the photo was taken
+ var/location_taken
+ /// Who took the photo
+ var/taken_by
+ /// The photo's name/caption
+ var/caption
drop_sound = 'sound/items/drop/paper.ogg'
pickup_sound = 'sound/items/pickup/paper.ogg'
@@ -95,8 +109,10 @@ GLOBAL_VAR_INIT(photo_count, 0)
if(surface_atom == usr || surface_atom.Adjacent(usr))
if(n_name)
name = "[initial(name)] ([n_name])"
+ caption = n_name
else
name = initial(name)
+ caption = null
add_fingerprint(usr)
@@ -238,6 +254,12 @@ GLOBAL_VAR_INIT(photo_count, 0)
desc = "A one use - polaroid camera."
pictures_left = 30
+// Detective cameras have build in data recording, so no need for a skill check
+/obj/item/camera/detective/record_data(mob/living/user, obj/item/photo/pic)
+ pic.time_taken = worldtime2text()
+ pic.location_taken = get_area_display_name(get_area(user))
+ pic.taken_by = user.name
+
//Proc for capturing check
/mob/living/proc/can_capture_turf(turf/T)
return TRUE // DVIEW will do sanity checks, we've got no special checks.
@@ -288,9 +310,21 @@ GLOBAL_VAR_INIT(photo_count, 0)
p.pixel_x = rand(-10, 10)
p.pixel_y = rand(-10, 10)
p.photo_size = size
+ record_data(user, p)
return p
+/// Data recorded on photos.
+/obj/item/camera/proc/record_data(mob/living/user, obj/item/photo/pic)
+ var/forensic = GET_SKILL_LEVEL(user, FORENSICS_SKILL_COMPONENT)
+ forensic = forensic ? forensic : SKILL_LEVEL_TRAINED
+
+ // Trained people would know to add this data
+ if(forensic >= SKILL_LEVEL_FAMILIAR)
+ pic.time_taken = worldtime2text()
+ pic.location_taken = get_area_display_name(get_area(user))
+ pic.taken_by = user.name
+
/obj/item/camera/proc/printpicture(mob/user, obj/item/photo/p)
p.forceMove(user.loc)
if(!user.get_inactive_hand())
diff --git a/html/changelogs/CaseDossierFiles.yml b/html/changelogs/CaseDossierFiles.yml
new file mode 100644
index 00000000000..f84b5f6ad0c
--- /dev/null
+++ b/html/changelogs/CaseDossierFiles.yml
@@ -0,0 +1,10 @@
+author: TheGreyWolf
+
+delete-after: True
+
+changes:
+ - rscadd: "Investigators now have the case dossier program. The rest of security can also read it in read-only mode."
+ - rscadd: "Evidence bags now labels the time, person and place when an item is bagged, if they have forensic skill. This can also be editted with a pen."
+ - rscadd: "Photos can now record similar data to the evidence bags if the user has the forensic skill, or they are using a detective camera. This cannot be editted."
+ - rscadd: "It is now possible to gather fibers and prints from objects inside evidence bags."
+ - bugfix: "Fixed some stuff with photos that should make them able to take photos of certain things such as airlocks again."
diff --git a/tgui/packages/tgui/index.tsx b/tgui/packages/tgui/index.tsx
index 22e6a1ff72c..2c07a35a9a0 100644
--- a/tgui/packages/tgui/index.tsx
+++ b/tgui/packages/tgui/index.tsx
@@ -34,8 +34,8 @@ import './styles/themes/hephaestus.scss';
import './styles/themes/sol.scss';
import './styles/themes/vaurca.scss';
import './styles/themes/amberpos.scss';
+import './styles/themes/dossier.scss';
import './styles/themes/orion.scss';
-
import { setupGlobalEvents } from 'tgui-core/events';
import { setupHotKeys } from 'tgui-core/hotkeys';
import { captureExternalLinks } from 'tgui-core/links';
diff --git a/tgui/packages/tgui/interfaces/Casedossier.tsx b/tgui/packages/tgui/interfaces/Casedossier.tsx
new file mode 100644
index 00000000000..fd96175c818
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/Casedossier.tsx
@@ -0,0 +1,1948 @@
+import {
+ Box,
+ Button,
+ Dropdown,
+ Input,
+ LabeledList,
+ NoticeBox,
+ Section,
+ Stack,
+ Table,
+ Tabs,
+ TextArea,
+} from 'tgui-core/components';
+import { useBackend, useLocalState } from '../backend';
+import { NtosWindow } from '../layouts';
+import { sanitizeText } from '../sanitize';
+
+type CaseStatus = 'Open' | 'Submitted' | 'Archived';
+
+type ActFinal = (action: string, payload?: Record) => void;
+type SetStateFinal = (value: T) => void;
+
+type DossierPerson = {
+ id: string;
+ name: string;
+ role: string;
+ notes?: string;
+};
+
+type DossierEvidence = {
+ id: string;
+ label: string;
+ type: string;
+ location?: string;
+ evidence_locker?: string;
+ collected_by?: string;
+ collected_at?: string;
+ notes?: string;
+ linked_people?: string[];
+};
+
+type DossierPhoto = {
+ id: string;
+ photo_id?: string;
+ caption: string;
+ label?: string;
+ type?: string;
+ location?: string;
+ taken_by?: string;
+ taken_at?: string;
+ collected_by?: string;
+ collected_at?: string;
+ notes?: string;
+ scribble?: string;
+ linked_evidence?: string[];
+ image?: string;
+};
+
+type DossierReport = {
+ id: string;
+ title: string;
+ label?: string;
+ type: string;
+ author?: string;
+ created_at?: string;
+ collected_by?: string;
+ collected_at?: string;
+ notes?: string;
+
+ scanned_by?: string;
+ scanned_at?: string;
+ content?: string;
+};
+
+type InvestigationCase = {
+ id: string;
+ title: string;
+ status: CaseStatus;
+ investigator?: string;
+ created_at?: string;
+ updated_at?: string;
+ tags?: string[];
+
+ summary?: string;
+ timeline?: string;
+ findings?: string;
+
+ victims?: DossierPerson[];
+ suspects?: DossierPerson[];
+ witnesses?: DossierPerson[];
+
+ evidence?: DossierEvidence[];
+ photos?: DossierPhoto[];
+ reports?: DossierReport[];
+};
+
+type Data = {
+ open_case?: InvestigationCase;
+ cases: InvestigationCase[];
+ available_statuses: CaseStatus[];
+ can_edit: boolean;
+};
+
+export const Casedossier = () => {
+ const { act, data } = useBackend();
+ const {
+ open_case,
+ cases = [],
+ available_statuses = ['Open', 'Submitted', 'Archived'],
+ can_edit = false,
+ } = data;
+
+ const [tab, setTab] = useLocalState('case_dossier_tab', 'open');
+ const [caseFilter, setCaseFilter] = useLocalState(
+ 'case_dossier_filter',
+ 'All',
+ );
+ const [search, setSearch] = useLocalState('case_dossier_search', '');
+
+ const [readOnly, setReadOnly] = useLocalState(
+ 'case_dossier_read_only',
+ false,
+ );
+
+ const permissionReadOnly = readOnly || !can_edit;
+ const caseStatusReadOnly = !!open_case && open_case.status !== 'Open';
+ const effectiveOpenFileReadOnly = permissionReadOnly || caseStatusReadOnly;
+
+ return (
+
+
+
+ setTab('open')}
+ >
+ Open File
+
+ setTab('storage')}
+ >
+ Short-Term Storage
+
+
+
+
+ {!can_edit ? (
+
+ You do not have forensic access. This dossier is locked to
+ read-only mode.
+
+ ) : open_case && open_case.status !== 'Open' ? (
+
+ This case is {open_case.status.toLowerCase()} and is locked to
+ read-only mode.
+
+ ) : (
+
+ )}
+
+
+ {tab === 'open' && (
+
+ )}
+
+ {tab === 'storage' && (
+
+ )}
+
+
+ );
+};
+
+const OpenFileTab = (props: {
+ caseFile?: InvestigationCase;
+ statuses: CaseStatus[];
+ readOnly: boolean;
+ act: ActFinal;
+}) => {
+ const { caseFile, statuses, readOnly, act } = props;
+
+ const [notesOpen, setNotesOpen] = useLocalState(
+ 'case_dossier_notes_open',
+ true,
+ );
+ const [peopleOpen, setPeopleOpen] = useLocalState(
+ 'case_dossier_people_open',
+ true,
+ );
+ const [evidenceOpen, setEvidenceOpen] = useLocalState(
+ 'case_dossier_evidence_open',
+ true,
+ );
+ const [photosOpen, setPhotosOpen] = useLocalState(
+ 'case_dossier_photos_open',
+ true,
+ );
+ const [reportsOpen, setReportsOpen] = useLocalState(
+ 'case_dossier_reports_open',
+ true,
+ );
+
+ if (!caseFile) {
+ return (
+
+
+ No case file is currently open. Create or open a file from Short-Term
+ Storage.
+
+ {!readOnly && (
+
+ )}
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+ }
+ >
+
+
+
+
+
+ act('scan_photo')}>
+ Scan Held Photo
+
+ )
+ }
+ >
+
+
+
+
+
+ act('scan_report')}>
+ Scan Held Paper
+
+ )
+ }
+ >
+
+
+
+
+ );
+};
+
+const CaseHeader = (props: {
+ caseFile: InvestigationCase;
+ statuses: CaseStatus[];
+ readOnly: boolean;
+ act: ActFinal;
+}) => {
+ const { caseFile, statuses, readOnly, act } = props;
+
+ return (
+
+
+
+
+ {caseFile.id || 'Unnumbered'} - {caseFile.title || 'Untitled Case'}
+ {' - '}
+
+ {readOnly && (
+
+ Read-only
+
+ )}
+
+
+
+ Investigator: {caseFile.investigator || 'Unassigned'} | Created:{' '}
+ {caseFile.created_at || 'Unknown'} | Updated:{' '}
+ {caseFile.updated_at || 'Unknown'}
+
+
+
+
+
+
+
+ {!readOnly && (
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ {!readOnly && (
+ <>
+
+
+
+
+
+ act('edit_case_field', {
+ field: 'title',
+ value,
+ })
+ }
+ />
+
+
+
+
+ act('edit_case_field', {
+ field: 'status',
+ value,
+ })
+ }
+ />
+
+
+
+
+ act('edit_case_field', {
+ field: 'investigator',
+ value,
+ })
+ }
+ />
+
+
+
+
+ act('edit_case_tags', {
+ value,
+ })
+ }
+ />
+
+
+
+ {caseFile.created_at || 'Unknown'}
+
+
+ >
+ )}
+
+ );
+};
+
+const CaseNarrativeSection = (props: {
+ caseFile: InvestigationCase;
+ readOnly: boolean;
+ act: ActFinal;
+}) => {
+ const { caseFile, readOnly, act } = props;
+
+ return (
+
+
+
+ Summary
+
+
+ act('edit_case_field', {
+ field: 'summary',
+ value,
+ })
+ }
+ />
+
+
+
+
+
+
+ Timeline
+
+
+ act('edit_case_field', {
+ field: 'timeline',
+ value,
+ })
+ }
+ />
+
+
+
+
+ Findings
+
+
+ act('edit_case_field', {
+ field: 'findings',
+ value,
+ })
+ }
+ />
+
+
+
+
+ );
+};
+
+const CaseOverview = (props: { caseFile: InvestigationCase }) => {
+ const { caseFile } = props;
+ const evidenceCount = (caseFile.evidence || []).length;
+ const photoCount = (caseFile.photos || []).length;
+ const reportCount = (caseFile.reports || []).length;
+
+ return (
+
+
+
+
+
+
+ {getPeopleCount(caseFile)} total
+
+ {evidenceCount}
+ {photoCount}
+ {reportCount}
+
+
+ );
+};
+
+const CaseChecklist = (props: { caseFile: InvestigationCase }) => {
+ const { caseFile } = props;
+ const checks = [
+ {
+ label: 'Summary written',
+ done: hasText(caseFile.summary),
+ },
+ {
+ label: 'People listed',
+ done: getPeopleCount(caseFile) > 0,
+ },
+ {
+ label: 'Evidence attached',
+ done: (caseFile.evidence || []).length > 0,
+ },
+ {
+ label: 'Photo documentation attached',
+ done: (caseFile.photos || []).length > 0,
+ },
+ {
+ label: 'Reports or paperwork attached',
+ done: (caseFile.reports || []).length > 0,
+ },
+ {
+ label: 'Findings recorded',
+ done: hasText(caseFile.findings),
+ },
+ ];
+
+ return (
+
+ {checks.map((check) => (
+
+
+ {check.done ? '✓' : '✗'}
+ {' '}
+ {check.label}
+
+ ))}
+
+ );
+};
+
+const PeopleDashboard = (props: {
+ caseFile: InvestigationCase;
+ readOnly: boolean;
+ act: ActFinal;
+}) => {
+ const { caseFile, readOnly, act } = props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const PeopleSection = (props: {
+ title: string;
+ listKey: string;
+ people: DossierPerson[];
+ readOnly: boolean;
+ act: ActFinal;
+}) => {
+ const { title, listKey, people, readOnly, act } = props;
+
+ return (
+
+ act('add_person', {
+ list: listKey,
+ })
+ }
+ >
+ Add
+
+ )
+ }
+ >
+ {!people.length ? (
+ No {title.toLowerCase()} listed.
+ ) : (
+
+
+ ID
+ Name
+ Role
+ Notes
+ {!readOnly && }
+
+
+ {people.map((person) => (
+
+ {person.id}
+
+
+ act('edit_person', {
+ list: listKey,
+ id: person.id,
+ field: 'name',
+ value,
+ })
+ }
+ />
+
+
+
+
+ act('edit_person', {
+ list: listKey,
+ id: person.id,
+ field: 'role',
+ value,
+ })
+ }
+ />
+
+
+
+
+ act('edit_person', {
+ list: listKey,
+ id: person.id,
+ field: 'notes',
+ value,
+ })
+ }
+ />
+
+
+ {!readOnly && (
+
+
+ )}
+
+ ))}
+
+ )}
+
+ );
+};
+
+const EvidenceSection = (props: {
+ evidence: DossierEvidence[];
+ people: DossierPerson[];
+ readOnly: boolean;
+ act: ActFinal;
+}) => {
+ const { evidence, people, readOnly, act } = props;
+
+ return !evidence.length ? (
+
+ No evidence attached. Scan held evidence or add a manual entry.
+
+ ) : (
+
+ {evidence.map((item) => (
+
+
+
+ ))}
+
+ );
+};
+
+const LinkedPeopleSelector = (props: {
+ item: DossierEvidence;
+ people: DossierPerson[];
+ readOnly: boolean;
+ act: ActFinal;
+}) => {
+ const { item, people, readOnly, act } = props;
+ const linkedPeople = item.linked_people || [];
+
+ if (readOnly) {
+ return (
+ {
+ const person = people.find(
+ (candidate) => candidate.id === personId,
+ );
+
+ return person
+ ? `${person.name || person.id} (${person.role || 'No role'})`
+ : personId;
+ })
+ .join(', ')}
+ empty="No linked people."
+ />
+ );
+ }
+
+ const options = people
+ .filter((person) => !linkedPeople.includes(person.id))
+ .map((person) => ({
+ displayText: `${person.id} - ${person.name || 'Unnamed'} (${person.role || 'No role'})`,
+ value: person.id,
+ }));
+
+ const setLinkedPeople = (newLinkedPeople: string[]) => {
+ act('edit_evidence', {
+ id: item.id,
+ field: 'linked_people',
+ value: newLinkedPeople.join(', '),
+ });
+ };
+
+ return (
+
+
+ {
+ setLinkedPeople([...linkedPeople, value]);
+ }}
+ />
+
+
+ {!!linkedPeople.length && (
+
+
+ {linkedPeople.map((personId) => {
+ const person = people.find(
+ (candidate) => candidate.id === personId,
+ );
+
+ return (
+
+
+
+ );
+ })}
+
+
+ )}
+
+ );
+};
+
+const EvidenceEntry = (props: {
+ item: DossierEvidence;
+ people: DossierPerson[];
+ readOnly: boolean;
+ act: ActFinal;
+}) => {
+ const { item, people, readOnly, act } = props;
+
+ const [open, setOpen] = useLocalState(
+ `case_dossier_evidence_${item.id}_open`,
+ false,
+ );
+
+ const linkedPeople = item.linked_people || [];
+ const linkedPeopleText = linkedPeople
+ .map((personId) => {
+ const person = people.find((candidate) => candidate.id === personId);
+
+ return person
+ ? `${person.name || person.id} (${person.role || 'No role'})`
+ : personId;
+ })
+ .join(', ');
+
+ return (
+
+
+
+
+ {item.id} - {item.label || 'Unnamed evidence'}
+
+
+
+ {item.type || 'No type'} | {item.location || 'No location'}
+
+
+
+ Locker: {item.evidence_locker || 'No locker'} | Collected by:{' '}
+ {item.collected_by || 'Unknown'} | {item.collected_at || 'Unknown'}
+
+
+ {!!linkedPeople.length && (
+
+
+ Linked:
+
+ {linkedPeople.map((personId) => {
+ const person = people.find(
+ (candidate) => candidate.id === personId,
+ );
+
+ return (
+
+ {person
+ ? `${person.name || person.id} (${person.role || 'No role'})`
+ : personId}
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+
+ {open && (
+ <>
+
+
+ {readOnly ? (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : (
+
+
+
+
+
+
+
+ act('edit_evidence', {
+ id: item.id,
+ field: 'label',
+ value,
+ })
+ }
+ />
+
+
+
+
+ act('edit_evidence', {
+ id: item.id,
+ field: 'type',
+ value,
+ })
+ }
+ />
+
+
+
+
+ act('edit_evidence', {
+ id: item.id,
+ field: 'location',
+ value,
+ })
+ }
+ />
+
+
+
+
+
+
+
+
+ act('edit_evidence', {
+ id: item.id,
+ field: 'evidence_locker',
+ value,
+ })
+ }
+ />
+
+
+
+
+ act('edit_evidence', {
+ id: item.id,
+ field: 'collected_by',
+ value,
+ })
+ }
+ />
+
+
+
+
+ act('edit_evidence', {
+ id: item.id,
+ field: 'collected_at',
+ value,
+ })
+ }
+ />
+
+
+
+
+
+
+
+
+ Linked People
+
+
+
+
+ )}
+ >
+ )}
+
+ );
+};
+
+const PhotoSection = (props: {
+ photos: DossierPhoto[];
+ readOnly: boolean;
+ act: ActFinal;
+}) => {
+ const { photos, readOnly, act } = props;
+
+ return !photos.length ? (
+ No photos attached. Scan a held photo to attach it.
+ ) : (
+
+ {photos.map((photo, index) => (
+
+ 0
+ ? 'CaseDossier__photoEntry CaseDossier__photoEntry--divided'
+ : 'CaseDossier__photoEntry'
+ }
+ >
+
+
+
+ ))}
+
+ );
+};
+
+const PhotoEntry = (props: {
+ photo: DossierPhoto;
+ readOnly: boolean;
+ act: ActFinal;
+}) => {
+ const { photo, readOnly, act } = props;
+
+ const [open, setOpen] = useLocalState(
+ `case_dossier_photo_${photo.id}_open`,
+ true,
+ );
+
+ return (
+
+
+
+
+ {photo.id} - {photo.caption || 'No caption'}
+
+
+
+ {photo.location || 'No location'} | Linked evidence:{' '}
+ {(photo.linked_evidence || []).join(', ') || 'None'}
+
+
+
+ Taken by: {photo.taken_by || 'Unknown'} |{' '}
+ {photo.taken_at || 'Unknown time'}
+
+
+
+
+ setOpen(!open)}
+ />
+
+ {!readOnly && (
+
+ act('remove_photo', {
+ id: photo.id,
+ })
+ }
+ />
+ )}
+
+
+
+ {open && (
+ <>
+
+
+ {!readOnly && (
+
+
+
+
+
+
+
+ act('edit_photo', {
+ id: photo.id,
+ field: 'caption',
+ value,
+ })
+ }
+ />
+
+
+
+
+ act('edit_photo', {
+ id: photo.id,
+ field: 'location',
+ value,
+ })
+ }
+ />
+
+
+
+
+ act('edit_photo', {
+ id: photo.id,
+ field: 'linked_evidence',
+ value,
+ })
+ }
+ />
+
+
+
+
+
+
+
+
+ act('edit_photo', {
+ id: photo.id,
+ field: 'taken_by',
+ value,
+ })
+ }
+ />
+
+
+
+
+ act('edit_photo', {
+ id: photo.id,
+ field: 'taken_at',
+ value,
+ })
+ }
+ />
+
+
+
+
+
+
+ )}
+
+
+
+ Description
+
+
+
+ act('edit_photo', {
+ id: photo.id,
+ field: 'notes',
+ value,
+ })
+ }
+ />
+
+
+ {!!photo.scribble && (
+
+ Written on the back: {photo.scribble}
+
+ )}
+
+ {!!photo.image && (
+
+
+
+ )}
+ >
+ )}
+
+ );
+};
+
+const ReportSection = (props: {
+ reports: DossierReport[];
+ readOnly: boolean;
+ act: ActFinal;
+}) => {
+ const { reports, readOnly, act } = props;
+
+ return !reports.length ? (
+ No reports attached. Scan held paperwork.
+ ) : (
+
+ {reports.map((report) => (
+
+
+
+ ))}
+
+ );
+};
+
+const ReportEntry = (props: {
+ report: DossierReport;
+ readOnly: boolean;
+ act: ActFinal;
+}) => {
+ const { report, readOnly, act } = props;
+
+ const [open, setOpen] = useLocalState(
+ `case_dossier_report_${report.id}_open`,
+ false,
+ );
+
+ const contentHtml = {
+ __html: sanitizeText(report.content || ''),
+ };
+
+ return (
+
+
+
+
+ {report.id} - {report.title || 'Untitled paperwork'}
+
+
+
+ {report.type || 'Scanned paper'} | Scanned by:{' '}
+ {report.scanned_by || report.collected_by || 'Unknown'} |{' '}
+ {report.scanned_at || report.collected_at || 'Unknown time'}
+
+
+
+
+ setOpen(!open)}
+ />
+
+ {!readOnly && (
+
+ act('remove_report', {
+ id: report.id,
+ })
+ }
+ />
+ )}
+
+
+
+ {open && (
+ <>
+
+
+
+ This is a scanned copy of paperwork. It cannot be edited from the
+ case dossier.
+
+
+
+ {report.content ? (
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: Is sanitized by DOMPurify.
+
+ ) : (
+
+ This scanned paper was blank.
+
+ )}
+
+ >
+ )}
+
+ );
+};
+
+const ShortTermStorageTab = (props: {
+ cases: InvestigationCase[];
+ caseFilter: string;
+ setCaseFilter: SetStateFinal;
+ search: string;
+ setSearch: SetStateFinal;
+ setTab: SetStateFinal;
+ readOnly: boolean;
+ act: ActFinal;
+}) => {
+ const {
+ cases,
+ caseFilter,
+ setCaseFilter,
+ search,
+ setSearch,
+ setTab,
+ readOnly,
+ act,
+ } = props;
+
+ const loweredSearch = search.toLowerCase();
+
+ const visibleCases = cases.filter((caseFile) => {
+ const matchesStatus =
+ caseFilter === 'All' || caseFile.status === caseFilter;
+
+ const searchable = [
+ caseFile.id,
+ caseFile.title,
+ caseFile.investigator,
+ ...(caseFile.tags || []),
+ ]
+ .join(' ')
+ .toLowerCase();
+
+ const matchesSearch = !loweredSearch || searchable.includes(loweredSearch);
+
+ return matchesStatus && matchesSearch;
+ });
+
+ return (
+
+
+
+
+
+
+
+ {!visibleCases.length ? (
+ No matching case files.
+ ) : (
+
+ {visibleCases.map((caseFile) => (
+
+
+
+
+
+ {caseFile.id || 'Unnumbered'} -{' '}
+ {caseFile.title || 'Untitled Case'}
+ {' - '}
+
+
+
+ Investigator: {caseFile.investigator || 'Unassigned'}{' '}
+ | Updated: {caseFile.updated_at || 'Unknown'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ act('open_case', {
+ id: caseFile.id,
+ });
+ setTab('open');
+ }}
+ >
+ Open
+
+
+ {!readOnly && (
+
+ act('duplicate_case', {
+ id: caseFile.id,
+ })
+ }
+ >
+ Copy
+
+ )}
+
+
+ {!readOnly && (
+
+ {caseFile.status !== 'Open' && (
+
+ act('set_case_status', {
+ id: caseFile.id,
+ status: 'Open',
+ })
+ }
+ >
+ Reopen
+
+ )}
+
+
+ act('set_case_status', {
+ id: caseFile.id,
+ status: 'Archived',
+ })
+ }
+ >
+ Archive
+
+
+
+ act('delete_case', {
+ id: caseFile.id,
+ })
+ }
+ >
+ Delete
+
+
+ )}
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+ );
+};
+
+const CollapsibleSection = (props: {
+ title: string;
+ count?: number;
+ total?: number;
+ open: boolean;
+ setOpen: SetStateFinal;
+ buttons?: any;
+ children: any;
+}) => {
+ const { title, count, total, open, setOpen, buttons, children } = props;
+ const titleText =
+ typeof count === 'number'
+ ? total
+ ? `${title} — ${count}/${total}`
+ : `${title} — ${count}`
+ : title;
+
+ return (
+
+ {buttons}
+ setOpen(!open)}
+ />
+ >
+ }
+ >
+ {open ? children : Section collapsed.}
+
+ );
+};
+
+const ReadOnlyValue = (props: { value?: any; empty?: string }) => {
+ const { value, empty = '—' } = props;
+ const text = hasText(value) ? String(value) : empty;
+
+ return (
+
+ {text}
+
+ );
+};
+
+const EditableInput = (props: {
+ readOnly: boolean;
+ value?: string;
+ empty?: string;
+ [key: string]: any;
+}) => {
+ const { readOnly, value, empty, ...rest } = props;
+
+ if (readOnly) {
+ return ;
+ }
+
+ return ;
+};
+
+const EditableTextArea = (props: {
+ readOnly: boolean;
+ value?: string;
+ empty?: string;
+ [key: string]: any;
+}) => {
+ const { readOnly, value, empty, ...rest } = props;
+
+ if (readOnly) {
+ return ;
+ }
+
+ return ;
+};
+
+const EditableDropdown = (props: {
+ readOnly: boolean;
+ selected?: any;
+ options: any[];
+ onSelected: (value: any) => void;
+ empty?: string;
+ [key: string]: any;
+}) => {
+ const { readOnly, selected, options, onSelected, empty, ...rest } = props;
+
+ if (readOnly) {
+ return ;
+ }
+
+ return (
+
+ );
+};
+
+const StatusBadge = (props: { status: CaseStatus }) => {
+ const { status } = props;
+ return (
+
+ {status}
+
+ );
+};
+
+const CountBadge = (props: { label: string; count: number }) => {
+ const { label, count } = props;
+ return (
+
+ {label}: {count}
+
+ );
+};
+
+const TagList = (props: { tags: string[] }) => {
+ const { tags } = props;
+
+ if (!tags.length) {
+ return (
+
+ No tags.
+
+ );
+ }
+
+ return (
+
+
+ Tags:
+
+ {tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ );
+};
+
+const EmptyState = (props: { children: any }) => {
+ return {props.children};
+};
+
+const hasText = (value) => {
+ return !!value && !!String(value).trim();
+};
+
+const getPeopleCount = (caseFile: InvestigationCase) => {
+ return (
+ (caseFile.victims || []).length +
+ (caseFile.suspects || []).length +
+ (caseFile.witnesses || []).length
+ );
+};
+
+const countCompletedNotes = (caseFile: InvestigationCase) => {
+ return [caseFile.summary, caseFile.timeline, caseFile.findings].filter(
+ hasText,
+ ).length;
+};
+
+const getAllPeople = (caseFile: InvestigationCase) => {
+ return [
+ ...(caseFile.victims || []),
+ ...(caseFile.suspects || []),
+ ...(caseFile.witnesses || []),
+ ];
+};
diff --git a/tgui/packages/tgui/styles/interfaces/CaseDossier.scss b/tgui/packages/tgui/styles/interfaces/CaseDossier.scss
new file mode 100644
index 00000000000..0045123b83c
--- /dev/null
+++ b/tgui/packages/tgui/styles/interfaces/CaseDossier.scss
@@ -0,0 +1,237 @@
+// Use with the dossier.scss theme
+.CaseDossier__caseTitle {
+ font-size: 1.35em;
+ font-weight: bold;
+ line-height: 1.3;
+ color: #eef5fb;
+ letter-spacing: 0.02em;
+}
+
+.CaseDossier__divider {
+ margin-top: 0.75rem;
+ margin-bottom: 0.75rem;
+ border-top: 1px solid rgba(255, 255, 255, 0.14);
+ background-color: rgba(159, 179, 200, 0.32);
+}
+
+.CaseDossier__badge {
+ display: inline-block;
+ margin-left: 0.45rem;
+ margin-right: 0.25rem;
+ padding: 0.1rem 0.45rem;
+ border-radius: 0.35rem;
+ font-size: 0.85em;
+ font-weight: bold;
+ letter-spacing: 0.02em;
+ background: rgba(255, 255, 255, 0.12);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+}
+
+.CaseDossier__badge--open {
+ background: rgba(80, 150, 255, 0.18);
+ border-color: rgba(87, 190, 124, 0.55);
+ background-color: rgba(87, 190, 124, 0.14);
+ color: #8be0a8;
+}
+
+.CaseDossier__badge--submitted {
+ background: rgba(255, 190, 80, 0.18);
+ border-color: rgba(215, 180, 95, 0.55);
+ background-color: rgba(215, 180, 95, 0.16);
+ color: #f0d48a;
+}
+
+.CaseDossier__badge--archived {
+ background: rgba(180, 180, 180, 0.12);
+ border-color: rgba(160, 174, 188, 0.45);
+ background-color: rgba(160, 174, 188, 0.12);
+ color: #c4d0dc;
+}
+
+.CaseDossier__badge--count {
+ margin-left: 0;
+ margin-bottom: 0.25rem;
+ font-weight: normal;
+ border-color: rgba(159, 179, 200, 0.38);
+ background-color: rgba(38, 63, 86, 0.26);
+ color: #d8e2ec;
+}
+
+.CaseDossier__tag {
+ display: inline-block;
+ margin-right: 0.35rem;
+ margin-bottom: 0.25rem;
+ padding: 0.08rem 0.4rem;
+ border-radius: 0.35rem;
+ background: rgba(255, 255, 255, 0.09);
+ color: rgba(255, 255, 255, 0.85);
+}
+
+.CaseDossier__check {
+ display: inline-block;
+ width: 1.25rem;
+ font-weight: bold;
+ text-align: center;
+}
+
+.CaseDossier__check--done {
+ color: #5dd384;
+}
+
+.CaseDossier__check--missing {
+ color: #cf5e5e;
+}
+
+.CaseDossier__emptyState {
+ padding: 0.75rem;
+ opacity: 0.72;
+ border: 1px dashed rgba(255, 255, 255, 0.16);
+ border-radius: 0.35rem;
+ border-color: rgba(159, 179, 200, 0.28);
+ background-color: rgba(5, 12, 18, 0.34);
+ color: #aebdca;
+}
+
+.CaseDossier__storageCard {
+ padding: 0.65rem;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 0.35rem;
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.CaseDossier__storageCard,
+.CaseDossier__photoCard,
+.CaseDossier__evidenceCard {
+ background-color: hsla(214, 28%, 7%, 0.78);
+ border: 1px solid hsla(214, 22%, 42%, 0.45);
+ box-shadow:
+ inset 0 0 0 1px hsla(210, 20%, 90%, 0.025),
+ 0 2px 6px hsla(0, 0%, 0%, 0.34);
+}
+
+.CaseDossier__storageCard:hover,
+.CaseDossier__photoCard:hover,
+.CaseDossier__evidenceCard:hover {
+ border-color: rgba(215, 180, 95, 0.5);
+}
+
+.CaseDossier__photoPreview {
+ max-width: 256px;
+ max-height: 256px;
+ object-fit: contain;
+ image-rendering: pixelated;
+ border: 1px solid rgba(255, 255, 255, 0.25);
+ background-color: rgba(0, 0, 0, 0.25);
+ padding: 2px;
+}
+
+.CaseDossier__photoCard {
+ padding: 0.65rem;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 0.35rem;
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.CaseDossier__photoEntry {
+ display: block;
+ width: 100%;
+}
+
+.CaseDossier__photoEntry--divided {
+ margin-top: 0.75rem;
+ padding-top: 0.75rem;
+ border-top: 1px solid rgba(255, 255, 255, 0.28);
+ border-top-color: rgba(215, 180, 95, 0.32);
+}
+
+.CaseDossier__modeBadge {
+ display: inline-block;
+ margin-left: 0.5rem;
+ padding: 0.1rem 0.45rem;
+ border-radius: 0.35rem;
+ font-size: 0.8em;
+ font-weight: bold;
+ background: rgba(120, 180, 255, 0.16);
+ border: 1px solid rgba(120, 180, 255, 0.25);
+}
+
+.CaseDossier__readonly {
+ min-height: 1.7em;
+ padding: 0.25rem 0.45rem;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 0.25rem;
+ background: rgba(255, 255, 255, 0.035);
+ white-space: pre-wrap;
+}
+
+.CaseDossier__readonly--empty {
+ opacity: 0.55;
+ font-style: italic;
+}
+
+.CaseDossier__entryTitle {
+ font-weight: bold;
+ font-size: 1.05em;
+ line-height: 1.3;
+ color: #eef5fb;
+}
+
+.CaseDossier__evidenceCard {
+ padding: 0.65rem;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 0.35rem;
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.CaseDossier__reportCard {
+ padding: 0.65rem;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 0.35rem;
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.CaseDossier__paperScan {
+ margin-top: 0.75rem;
+ padding: 1rem;
+ border: 1px solid rgba(80, 60, 35, 0.45);
+ border-radius: 0.25rem;
+ background: #f1ead8;
+ color: #1d160d;
+}
+
+.CaseDossier__paperContent {
+ min-height: 12rem;
+ max-height: 32rem;
+ overflow-y: auto;
+ font-family: Verdana, sans-serif;
+ font-size: 0.95em;
+ line-height: 1.45;
+ white-space: normal;
+ word-break: break-word;
+}
+
+.CaseDossier__paperContent table {
+ border-collapse: collapse;
+}
+
+.CaseDossier__paperContent td,
+.CaseDossier__paperContent th {
+ padding: 0.25rem 0.4rem;
+}
+
+.CaseDossier__paperContent img {
+ max-width: 100%;
+ height: auto;
+}
+
+.CaseDossier__paperContent .paper_field {
+ min-width: 4rem;
+ display: inline-block;
+ border-bottom: 1px solid rgba(80, 60, 35, 0.45);
+}
+
+.CaseDossier__tag {
+ border-color: rgba(159, 179, 200, 0.38);
+ background-color: rgba(38, 63, 86, 0.34);
+ color: #d8e2ec;
+}
diff --git a/tgui/packages/tgui/styles/main.scss b/tgui/packages/tgui/styles/main.scss
index ed7d56d4b10..21a09b851a6 100644
--- a/tgui/packages/tgui/styles/main.scss
+++ b/tgui/packages/tgui/styles/main.scss
@@ -51,6 +51,7 @@
@include meta.load-css('./interfaces/AnomalyTower.scss');
@include meta.load-css('./interfaces/CivCargoHoldTerminal.scss');
@include meta.load-css('./interfaces/EventLogger.scss');
+@include meta.load-css('./interfaces/CaseDossier.scss');
@include meta.load-css('./interfaces/AlertModal.scss');
@include meta.load-css('./interfaces/FactionSelect.scss');
@include meta.load-css('./interfaces/HealthAnalyzer.scss');
diff --git a/tgui/packages/tgui/styles/themes/dossier.scss b/tgui/packages/tgui/styles/themes/dossier.scss
new file mode 100644
index 00000000000..b0efab212a0
--- /dev/null
+++ b/tgui/packages/tgui/styles/themes/dossier.scss
@@ -0,0 +1,73 @@
+// To be combined with CaseDossier.scss in most cases
+.theme-dossier {
+ --color-base: #101922;
+ --color-primary: #d8e2ec;
+ --color-label: #d7b45f;
+ --color-bad: #ad2727;
+
+ --base-gradient-spread: 5;
+ --border-radius-unit: 2px;
+
+ --button-color: #d8e2ec;
+ --button-background-default: #263f56;
+ --button-background-disabled: #363636;
+ --button-background-selected: #c89b3c;
+ --button-background-caution: #be6209;
+ --button-background-danger: #ad2727;
+
+ --input-background: rgba(5, 12, 18, 0.72);
+ --input-border-color: #9fb3c8;
+ --input-border-color-focus: #d7b45f;
+ --number-input-border-color: #9fb3c8;
+ --number-input-border-color-active: #d7b45f;
+
+ --notice-box-background: #24394d;
+ --notice-box-color: var(--color-text-fixed-white);
+
+ --progress-bar-background: rgba(0, 0, 0, 0.5);
+ --progress-bar-fill: #c89b3c;
+
+ --tooltip-background: #182735;
+ --titlebar-background: #111c26;
+
+ --section-background: rgba(0, 0, 0, 0.36);
+ --section-separator-color: #31475d;
+
+ .Layout__content {
+ background-image: url('../../assets/bg-nanotrasen.svg');
+ }
+
+ .Section {
+ border-left: 2px solid rgba(215, 180, 95, 0.45);
+ }
+
+ .Section__titleText {
+ color: #d7b45f;
+ letter-spacing: 0.035em;
+ text-transform: uppercase;
+ }
+
+ .Tabs__tab--selected {
+ color: #f0d48a;
+ }
+
+ .Table {
+ background-color: rgba(5, 12, 18, 0.42);
+ border: 1px solid rgba(159, 179, 200, 0.24);
+ }
+
+ .Table__row--header {
+ color: #f0d48a;
+ background-color: rgba(38, 63, 86, 0.42);
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ }
+
+ .Table__cell {
+ border-bottom: 1px solid rgba(159, 179, 200, 0.13);
+ }
+
+ .NoticeBox {
+ border-left: 3px solid #d7b45f;
+ }
+}