410 lines
18 KiB
GDScript
410 lines
18 KiB
GDScript
class_name StaffManagementPanel
|
||
extends VBoxContainer
|
||
|
||
## Roster and assignment editing only. Runtime owns Shift, staffing, movement,
|
||
## and persistence; this panel never edits a Staff or Shift instance directly.
|
||
signal status_requested(message: String, success: bool)
|
||
|
||
const ASSIGNMENT_COMMAND: StringName = &"set_staff_work_assignment"
|
||
const KEY_PREFIX := "ui.staff_assignment."
|
||
const ROLE_NAMES := {
|
||
&"attendant": "接客", &"bartender": "酒場", &"clerk": "会計",
|
||
&"cleaner": "清掃", &"cook": "調理", &"guard": "警備",
|
||
&"maintenance": "保守", &"masseur": "施術", &"medic": "医療",
|
||
&"porter": "物流", &"receptionist": "受付",
|
||
}
|
||
const STATE_NAMES := ["勤務外", "出勤中", "勤務中", "移動中", "作業中", "休憩中", "食事中", "睡眠中", "治療へ移動", "避難中", "行動不能", "ストライキ", "拘束中", "捕虜", "勤務不可"]
|
||
|
||
var summary_label: Label
|
||
var roster_list: VBoxContainer
|
||
var roster_buttons: Dictionary = {}
|
||
var editor_panel: VBoxContainer
|
||
var staff_detail_label: Label
|
||
var facility_option: OptionButton
|
||
var shift_option: OptionButton
|
||
var apply_button: Button
|
||
var result_label: Label
|
||
var selected_staff_id: StringName = &""
|
||
|
||
var _runtime: Variant
|
||
var _style_button: Callable
|
||
var _staff_rows: Array[Dictionary] = []
|
||
var _facility_rows: Array[Dictionary] = []
|
||
var _draft_facility_id: StringName = &""
|
||
var _draft_template_id: StringName = &"DAY"
|
||
var _draft_dirty := false
|
||
var _sequence := 1
|
||
var _pending_commands: Dictionary = {}
|
||
var _queue_instance_id := 0
|
||
var _built := false
|
||
|
||
|
||
func configure(runtime: Variant, style_button := Callable()) -> void:
|
||
_runtime = runtime
|
||
_style_button = style_button
|
||
|
||
|
||
func _ready() -> void:
|
||
name = "StaffManagementPanel"
|
||
size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
add_theme_constant_override("separation", 5)
|
||
_build_interface()
|
||
_built = true
|
||
if _runtime != null:
|
||
if _runtime.has_signal(&"snapshot_published"):
|
||
_runtime.connect(&"snapshot_published", _on_snapshot_published)
|
||
if _runtime.has_signal(&"command_completed"):
|
||
_runtime.connect(&"command_completed", _on_command_completed)
|
||
refresh()
|
||
|
||
|
||
func _build_interface() -> void:
|
||
summary_label = _label("まだ従業員を雇っていません。候補から雇用してください。")
|
||
summary_label.name = "HiredStaffSummary"
|
||
add_child(summary_label)
|
||
var scroll := ScrollContainer.new()
|
||
scroll.name = "HiredStaffScroll"
|
||
scroll.custom_minimum_size.y = 120
|
||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||
add_child(scroll)
|
||
roster_list = VBoxContainer.new()
|
||
roster_list.name = "HiredStaffList"
|
||
roster_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
roster_list.add_theme_constant_override("separation", 3)
|
||
scroll.add_child(roster_list)
|
||
editor_panel = VBoxContainer.new()
|
||
editor_panel.name = "StaffAssignmentEditor"
|
||
editor_panel.add_theme_constant_override("separation", 5)
|
||
add_child(editor_panel)
|
||
staff_detail_label = _label("")
|
||
staff_detail_label.name = "StaffAssignmentDetail"
|
||
editor_panel.add_child(staff_detail_label)
|
||
editor_panel.add_child(_label("配属先"))
|
||
facility_option = OptionButton.new()
|
||
facility_option.name = "StaffFacilityOption"
|
||
facility_option.fit_to_longest_item = false
|
||
facility_option.custom_minimum_size = Vector2(0, 30)
|
||
facility_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
facility_option.item_selected.connect(_on_facility_changed)
|
||
_style(facility_option)
|
||
editor_panel.add_child(facility_option)
|
||
editor_panel.add_child(_label("勤務帯"))
|
||
shift_option = OptionButton.new()
|
||
shift_option.name = "StaffShiftOption"
|
||
shift_option.fit_to_longest_item = false
|
||
shift_option.custom_minimum_size.y = 30
|
||
shift_option.add_item("日勤 06:00〜18:00")
|
||
shift_option.set_item_metadata(0, &"DAY")
|
||
shift_option.add_item("夜勤 18:00〜翌06:00")
|
||
shift_option.set_item_metadata(1, &"NIGHT")
|
||
shift_option.item_selected.connect(_on_shift_changed)
|
||
_style(shift_option)
|
||
editor_panel.add_child(shift_option)
|
||
apply_button = Button.new()
|
||
apply_button.name = "ApplyStaffAssignment"
|
||
apply_button.text = "配属・勤務帯を変更"
|
||
apply_button.custom_minimum_size.y = 32
|
||
apply_button.pressed.connect(submit_assignment)
|
||
_style(apply_button)
|
||
editor_panel.add_child(apply_button)
|
||
result_label = _label("")
|
||
result_label.name = "StaffAssignmentResult"
|
||
add_child(result_label)
|
||
|
||
|
||
func refresh() -> void:
|
||
if not _built or _runtime == null:
|
||
return
|
||
var queue: Variant = _runtime.get(&"command_queue")
|
||
if queue != null and int(queue.get_instance_id()) != _queue_instance_id:
|
||
_queue_instance_id = int(queue.get_instance_id())
|
||
_draft_dirty = false
|
||
facility_option.get_popup().hide()
|
||
shift_option.get_popup().hide()
|
||
_synchronize_commands(queue)
|
||
_read_roster()
|
||
var live_ids: Array[StringName] = []
|
||
var unassigned := 0
|
||
for row: Dictionary in _staff_rows:
|
||
var staff_id := StringName(row.get(&"staff_id", &""))
|
||
live_ids.append(staff_id)
|
||
if StringName(row.get(&"assigned_facility_id", &"")).is_empty():
|
||
unassigned += 1
|
||
var button := roster_buttons.get(staff_id) as Button
|
||
if button == null:
|
||
button = Button.new()
|
||
button.name = "HiredStaff_%s" % staff_id
|
||
button.custom_minimum_size.y = 32
|
||
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
button.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||
button.toggle_mode = true
|
||
button.pressed.connect(select_staff.bind(staff_id))
|
||
_style(button)
|
||
roster_list.add_child(button)
|
||
roster_buttons[staff_id] = button
|
||
button.text = "%s %s%s" % [row.get(&"display_name", staff_id), _role_name(row), "・未配属" if StringName(row.get(&"assigned_facility_id", &"")).is_empty() else ""]
|
||
button.tooltip_text = "%s / %s" % [_facility_name(StringName(row.get(&"assigned_facility_id", &""))), _state_name(row)]
|
||
for raw_id: Variant in roster_buttons.keys():
|
||
if not live_ids.has(StringName(raw_id)):
|
||
var stale := roster_buttons[raw_id] as Button
|
||
roster_list.remove_child(stale)
|
||
stale.queue_free()
|
||
roster_buttons.erase(raw_id)
|
||
if not live_ids.has(selected_staff_id):
|
||
selected_staff_id = live_ids[0] if not live_ids.is_empty() else &""
|
||
_draft_dirty = false
|
||
for raw_id: Variant in roster_buttons:
|
||
(roster_buttons[raw_id] as Button).set_pressed_no_signal(StringName(raw_id) == selected_staff_id)
|
||
summary_label.text = "雇用済み %d人 / 未配属 %d人" % [_staff_rows.size(), unassigned] if not _staff_rows.is_empty() else "従業員はまだいません。「候補を見る」から雇用してください。"
|
||
var scroll := find_child("HiredStaffScroll", true, false) as ScrollContainer
|
||
scroll.visible = not _staff_rows.is_empty()
|
||
scroll.custom_minimum_size.y = mini(maxi(_staff_rows.size(), 1) * 35, 140)
|
||
editor_panel.visible = not selected_staff_id.is_empty()
|
||
if editor_panel.visible:
|
||
_refresh_editor()
|
||
|
||
|
||
func select_staff(staff_id: StringName) -> void:
|
||
selected_staff_id = staff_id
|
||
_draft_dirty = false
|
||
result_label.text = ""
|
||
refresh()
|
||
|
||
|
||
func _refresh_editor() -> void:
|
||
if facility_option.get_popup().visible or shift_option.get_popup().visible:
|
||
return
|
||
var row := _selected_row()
|
||
if row.is_empty():
|
||
return
|
||
var current_facility := StringName(row.get(&"assigned_facility_id", &""))
|
||
var template := StringName(String(row.get(&"template_id", "DAY")).to_upper())
|
||
if not _draft_dirty:
|
||
_draft_facility_id = current_facility
|
||
_draft_template_id = &"NIGHT" if template == &"NIGHT" else &"DAY"
|
||
staff_detail_label.text = "%s(%s) 日給 G%d\n配属:%s / %s\n%s 疲労 %d / 未払 G%d" % [
|
||
row.get(&"display_name", selected_staff_id), _role_name(row), int(row.get(&"daily_wage", 0)),
|
||
_facility_name(current_facility), "夜勤" if template == &"NIGHT" else "日勤" if template == &"DAY" else "自由勤務",
|
||
_state_name(row), int(row.get(&"fatigue", 0)), int(row.get(&"unpaid_wages", 0)),
|
||
]
|
||
var effective_from := int(row.get(&"effective_from", -1))
|
||
var now := int(get_node("/root/GameClock").get("absolute_minute"))
|
||
if effective_from > now:
|
||
staff_detail_label.text += "\n反映:%sから" % _minute_text(effective_from)
|
||
if row.has(&"recurring") and bool(row.get(&"recurring", false)):
|
||
staff_detail_label.text += "\nこの勤務帯を毎日くり返します。"
|
||
facility_option.clear()
|
||
facility_option.add_item("未配属(施設担当を外す)")
|
||
facility_option.set_item_metadata(0, &"")
|
||
var selected_index := 0 if _draft_facility_id.is_empty() else -1
|
||
for facility: Dictionary in _facility_rows:
|
||
var facility_id := StringName(facility.get(&"facility_id", &""))
|
||
facility_option.add_item(_facility_name(facility_id))
|
||
var index := facility_option.item_count - 1
|
||
facility_option.set_item_metadata(index, facility_id)
|
||
if facility_id == _draft_facility_id:
|
||
selected_index = index
|
||
if selected_index < 0:
|
||
facility_option.add_item("選択した施設は現在存在しません")
|
||
selected_index = facility_option.item_count - 1
|
||
facility_option.set_item_metadata(selected_index, _draft_facility_id)
|
||
facility_option.set_item_disabled(selected_index, true)
|
||
facility_option.select(selected_index)
|
||
shift_option.select(1 if _draft_template_id == &"NIGHT" else 0)
|
||
var pending := _pending_commands.values().has(selected_staff_id)
|
||
facility_option.disabled = pending
|
||
shift_option.disabled = pending
|
||
var can_edit := bool(row.get(&"can_edit", true))
|
||
apply_button.disabled = pending or not can_edit or facility_option.is_item_disabled(selected_index)
|
||
apply_button.tooltip_text = "現在の作業や拘束が終わってから変更できます。" if not can_edit else "配属先と毎日の勤務帯をまとめて変更します。"
|
||
if not can_edit:
|
||
staff_detail_label.text += "\n現在の作業や拘束が終わってから変更できます。"
|
||
apply_button.text = "変更を受理済み…" if pending else "配属・勤務帯を変更"
|
||
|
||
|
||
func _on_facility_changed(index: int) -> void:
|
||
_draft_facility_id = StringName(facility_option.get_item_metadata(index))
|
||
_draft_dirty = true
|
||
|
||
|
||
func _on_shift_changed(index: int) -> void:
|
||
_draft_template_id = StringName(shift_option.get_item_metadata(index))
|
||
_draft_dirty = true
|
||
|
||
|
||
func submit_assignment() -> void:
|
||
if _runtime == null or selected_staff_id.is_empty():
|
||
return
|
||
if _pending_commands.values().has(selected_staff_id):
|
||
_set_result("この従業員の変更はすでに受理されています。", false)
|
||
return
|
||
var key := StringName("%s%08d" % [KEY_PREFIX, _sequence])
|
||
_sequence += 1
|
||
var result: Variant = _runtime.call(&"submit_command", ASSIGNMENT_COMMAND, {
|
||
&"staff_id": selected_staff_id,
|
||
&"facility_id": _draft_facility_id,
|
||
&"template_id": _draft_template_id,
|
||
}, &"player", selected_staff_id, 75, -1, key)
|
||
if result == null or not result.has_method(&"is_success"):
|
||
_set_result("配属変更に応答がありません。", false)
|
||
return
|
||
var pending := false
|
||
var queue: Variant = _runtime.get(&"command_queue")
|
||
if queue != null:
|
||
for command: Dictionary in queue.create_snapshot().get("pending", []):
|
||
if int(command.get("command_id", 0)) == int(result.command_id):
|
||
pending = true
|
||
break
|
||
if result.is_success() and pending:
|
||
_pending_commands[int(result.command_id)] = selected_staff_id
|
||
_set_result("変更を受理しました。次の時間更新で反映します。", true)
|
||
else:
|
||
_show_command_result(result)
|
||
refresh()
|
||
|
||
|
||
func _show_command_result(result: Variant, staff_id: StringName = &"") -> void:
|
||
var target_id := staff_id
|
||
if target_id.is_empty():
|
||
target_id = StringName(result.details.get(&"staff_id", selected_staff_id))
|
||
var employee_name := String(target_id)
|
||
for row: Dictionary in _staff_rows:
|
||
if StringName(row.get(&"staff_id", &"")) == target_id:
|
||
employee_name = String(row.get(&"display_name", target_id))
|
||
break
|
||
if result.is_success():
|
||
var details: Dictionary = result.details if result.details is Dictionary else {}
|
||
if target_id == selected_staff_id:
|
||
_draft_dirty = false
|
||
var facility_id := StringName(details.get(&"facility_id", _draft_facility_id))
|
||
var template := StringName(details.get(&"template_id", _draft_template_id))
|
||
var start := int(details.get(&"effective_from", -1))
|
||
var when := "%sから" % _minute_text(start) if start >= 0 else ""
|
||
_set_result("%s:%s%s・毎日%sに変更しました。" % [employee_name, when, _facility_name(facility_id), "夜勤" if template == &"NIGHT" else "日勤"], true)
|
||
else:
|
||
_set_result("%s:%s" % [employee_name, _failure_message(result)], false)
|
||
|
||
|
||
func _failure_message(result: Variant) -> String:
|
||
var details: Dictionary = result.details if result.details is Dictionary else {}
|
||
var reason := StringName(details.get(&"reason", details.get("reason", &"")))
|
||
var reasons := {
|
||
&"STAFF_NOT_FOUND": "従業員が見つかりません。",
|
||
&"FACILITY_NOT_FOUND": "配属先が見つかりません。施設が完成しているか確認してください。",
|
||
&"ROLE_MISMATCH": "この施設には役割が合いません。必要な職種を確認してください。",
|
||
&"NO_ACCESS": "従業員が配属先へ移動できません。通路と従業員ドアを確認してください。",
|
||
&"NO_PATH": "従業員が配属先へ移動できません。通路と従業員ドアを確認してください。",
|
||
&"STAFF_BUSY": "作業中のため変更できません。作業完了後に再試行してください。",
|
||
&"CAPACITY_EXCEEDED": "配属先の人員上限を超えています。",
|
||
&"FACILITY_FULL": "配属先の人員上限を超えています。",
|
||
&"NO_ROUTE": "配属先へ移動できません。通路と従業員ドアを確認してください。",
|
||
&"INVALID_TEMPLATE": "日勤または夜勤を選択してください。",
|
||
&"STAFF_UNAVAILABLE": "この従業員は現在、勤務を変更できません。状態をご確認ください。",
|
||
&"DEFENSE_LOCKED": "防衛参加中は勤務を変更できません。",
|
||
&"EVENT_LOCKED": "イベント参加中は勤務を変更できません。",
|
||
&"FACILITY_UNSTAFFED": "この施設は人員を配属する施設ではありません。",
|
||
&"SHIFT_CONFLICT": "既存の勤務と時間が重なります。勤務予定を確認してください。",
|
||
&"BANKRUPT": "運営終了後は配属を変更できません。",
|
||
}
|
||
if reasons.has(reason):
|
||
return reasons[reason]
|
||
return "変更できませんでした:%s" % String(result.message) if not String(result.message).is_empty() else "変更できませんでした。配属先と勤務帯を確認してください。"
|
||
|
||
|
||
func _synchronize_commands(queue: Variant) -> void:
|
||
_pending_commands.clear()
|
||
for metadata: Dictionary in queue.get_accepted_command_metadata():
|
||
var key := String(metadata.get("idempotency_key", ""))
|
||
if key.begins_with(KEY_PREFIX) and key.trim_prefix(KEY_PREFIX).is_valid_int():
|
||
_sequence = maxi(_sequence, int(key.trim_prefix(KEY_PREFIX)) + 1)
|
||
for command: Dictionary in queue.create_snapshot().get("pending", []):
|
||
if StringName(command.get("command_type", &"")) != ASSIGNMENT_COMMAND:
|
||
continue
|
||
var payload: Dictionary = command.get("payload", {})
|
||
_pending_commands[int(command.get("command_id", 0))] = StringName(payload.get(&"staff_id", payload.get("staff_id", &"")))
|
||
|
||
|
||
func _on_snapshot_published(_snapshot: Dictionary, _digest: String) -> void:
|
||
if is_visible_in_tree():
|
||
refresh()
|
||
|
||
|
||
func _on_command_completed(result: Variant) -> void:
|
||
if not _pending_commands.has(int(result.command_id)):
|
||
return
|
||
var staff_id := StringName(_pending_commands[int(result.command_id)])
|
||
_pending_commands.erase(int(result.command_id))
|
||
_show_command_result(result, staff_id)
|
||
refresh()
|
||
|
||
|
||
func _set_result(message: String, success: bool) -> void:
|
||
result_label.text = message
|
||
result_label.add_theme_color_override("font_color", Color("397346") if success else Color("a7402e"))
|
||
status_requested.emit(message, success)
|
||
|
||
|
||
func _read_roster() -> void:
|
||
_staff_rows.clear()
|
||
_facility_rows.clear()
|
||
if _runtime.has_method(&"get_staff_management_report"):
|
||
var report: Dictionary = _runtime.call(&"get_staff_management_report")
|
||
_staff_rows.assign(report.get(&"staff", []))
|
||
_facility_rows.assign(report.get(&"facilities", []))
|
||
return
|
||
_set_result("従業員データを読み込めません。", false)
|
||
|
||
|
||
func _selected_row() -> Dictionary:
|
||
for row: Dictionary in _staff_rows:
|
||
if StringName(row.get(&"staff_id", &"")) == selected_staff_id:
|
||
return row
|
||
return {}
|
||
|
||
|
||
func _facility_name(facility_id: StringName) -> String:
|
||
if facility_id.is_empty():
|
||
return "未配属"
|
||
for facility: Dictionary in _facility_rows:
|
||
if StringName(facility.get(&"facility_id", &"")) == facility_id:
|
||
var suffix := String(facility_id).trim_prefix("facility_").lstrip("0")
|
||
var location := ""
|
||
var origin: Variant = facility.get(&"origin", null)
|
||
if origin is Vector3i:
|
||
var floor_name := "%dF" % (origin.y + 1) if origin.y >= 0 else "B%d" % -origin.y
|
||
location = " %s・%s" % [floor_name, "客用" if int(facility.get(&"layer", 0)) == 0 else "裏方"]
|
||
return "%s%s #%s" % [facility.get(&"display_name", facility_id), location, suffix if not suffix.is_empty() else facility_id]
|
||
return "撤去済みの施設"
|
||
|
||
|
||
func _role_name(row: Dictionary) -> String:
|
||
var role := StringName(row.get(&"role_id", &""))
|
||
return String(ROLE_NAMES.get(role, String(role)))
|
||
|
||
|
||
func _state_name(row: Dictionary) -> String:
|
||
var value := int(row.get(&"state", 0))
|
||
return STATE_NAMES[value] if value >= 0 and value < STATE_NAMES.size() else "状態を確認中"
|
||
|
||
|
||
func _minute_text(absolute_minute: int) -> String:
|
||
var day := absolute_minute / 1440 + 1
|
||
var hour := posmod(absolute_minute, 1440) / 60
|
||
var minute := posmod(absolute_minute, 60)
|
||
return "%d日目 %02d:%02d" % [day, hour, minute]
|
||
|
||
|
||
func _label(text_value: String) -> Label:
|
||
var label := Label.new()
|
||
label.text = text_value
|
||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
label.add_theme_font_size_override("font_size", 12)
|
||
label.add_theme_color_override("font_color", Color("394937"))
|
||
return label
|
||
|
||
|
||
func _style(button: Button) -> void:
|
||
button.add_theme_font_size_override("font_size", 12)
|
||
if _style_button.is_valid():
|
||
_style_button.call(button)
|