Improve UI
- Add metrics table to sidebar after schedule generation - Add scheduler status indicator to sidebar - Refactor report() to consume `ResidentMetrics` - Delete unused preview component - Beautify css across wizard steps
This commit is contained in:
@@ -37,6 +37,8 @@ impl From<anyhow::Error> for SearchError {
|
|||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum ExportError {
|
pub enum ExportError {
|
||||||
|
#[error("no schedule has been generated yet")]
|
||||||
|
NotGenerated,
|
||||||
#[error("path not found: {0}")]
|
#[error("path not found: {0}")]
|
||||||
InvalidPath(#[from] io::Error),
|
InvalidPath(#[from] io::Error),
|
||||||
#[error("docx packaging error: {0}")]
|
#[error("docx packaging error: {0}")]
|
||||||
@@ -57,6 +59,7 @@ impl Serialize for ExportError {
|
|||||||
s.serialize_field(
|
s.serialize_field(
|
||||||
"kind",
|
"kind",
|
||||||
match self {
|
match self {
|
||||||
|
ExportError::NotGenerated => "NotGenerated",
|
||||||
ExportError::InvalidPath(_) => "InvalidPath",
|
ExportError::InvalidPath(_) => "InvalidPath",
|
||||||
ExportError::Packaging(_) => "Packaging",
|
ExportError::Packaging(_) => "Packaging",
|
||||||
ExportError::OpenFailed(_) => "OpenFailed",
|
ExportError::OpenFailed(_) => "OpenFailed",
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ use docx_rs::{Docx, Paragraph, Run, RunFonts, Table, TableCell, TableRow};
|
|||||||
use crate::{
|
use crate::{
|
||||||
config::UserConfig,
|
config::UserConfig,
|
||||||
errors::ExportError,
|
errors::ExportError,
|
||||||
schedule::MonthlySchedule,
|
schedule::{MonthlySchedule, ResidentMetrics},
|
||||||
slot::{Day, ShiftPosition, Slot},
|
slot::{Day, ShiftPosition, Slot},
|
||||||
workload::WorkloadTracker,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -21,7 +20,7 @@ pub trait Export {
|
|||||||
&self,
|
&self,
|
||||||
file_type: FileType,
|
file_type: FileType,
|
||||||
config: &UserConfig,
|
config: &UserConfig,
|
||||||
tracker: &WorkloadTracker,
|
metrics: &[ResidentMetrics],
|
||||||
) -> Result<(), ExportError>;
|
) -> Result<(), ExportError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,10 +29,10 @@ impl Export for MonthlySchedule {
|
|||||||
&self,
|
&self,
|
||||||
file_type: FileType,
|
file_type: FileType,
|
||||||
config: &UserConfig,
|
config: &UserConfig,
|
||||||
tracker: &WorkloadTracker,
|
metrics: &[ResidentMetrics],
|
||||||
) -> Result<(), ExportError> {
|
) -> Result<(), ExportError> {
|
||||||
match file_type {
|
match file_type {
|
||||||
FileType::Txt => self.export_as_txt(config, tracker)?,
|
FileType::Txt => self.export_as_txt(metrics)?,
|
||||||
FileType::Docx => self.export_as_docx(config)?,
|
FileType::Docx => self.export_as_docx(config)?,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -42,16 +41,11 @@ impl Export for MonthlySchedule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MonthlySchedule {
|
impl MonthlySchedule {
|
||||||
pub fn export_as_txt(
|
pub fn export_as_txt(&self, metrics: &[ResidentMetrics]) -> Result<(), ExportError> {
|
||||||
&self,
|
|
||||||
config: &UserConfig,
|
|
||||||
tracker: &WorkloadTracker,
|
|
||||||
) -> Result<(), ExportError> {
|
|
||||||
let file = File::create("rota.txt")?;
|
let file = File::create("rota.txt")?;
|
||||||
let mut writer = std::io::BufWriter::new(file);
|
let mut writer = std::io::BufWriter::new(file);
|
||||||
|
|
||||||
writer.write_all(self.pretty_print(config).as_bytes())?;
|
writer.write_all(self.report(metrics).as_bytes())?;
|
||||||
writer.write_all(self.report(config, tracker).as_bytes())?;
|
|
||||||
writer.flush()?;
|
writer.flush()?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use crate::{
|
|||||||
config::{UserConfig, UserConfigDTO},
|
config::{UserConfig, UserConfigDTO},
|
||||||
errors::{ExportError, SearchError},
|
errors::{ExportError, SearchError},
|
||||||
export::{Export, FileType},
|
export::{Export, FileType},
|
||||||
schedule::MonthlySchedule,
|
schedule::{MonthlySchedule, ResidentMetrics},
|
||||||
scheduler::Scheduler,
|
scheduler::Scheduler,
|
||||||
workload::WorkloadTracker,
|
workload::WorkloadTracker,
|
||||||
};
|
};
|
||||||
@@ -25,7 +25,7 @@ pub mod workload;
|
|||||||
struct AppState {
|
struct AppState {
|
||||||
schedule: Mutex<MonthlySchedule>,
|
schedule: Mutex<MonthlySchedule>,
|
||||||
tracker: Mutex<WorkloadTracker>,
|
tracker: Mutex<WorkloadTracker>,
|
||||||
config: Mutex<UserConfig>,
|
config: Mutex<Option<UserConfig>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// argument to this must be the rota state including all
|
/// argument to this must be the rota state including all
|
||||||
@@ -58,19 +58,33 @@ fn generate(
|
|||||||
|
|
||||||
*internal_schedule = schedule.clone();
|
*internal_schedule = schedule.clone();
|
||||||
*internal_tracker = tracker.clone();
|
*internal_tracker = tracker.clone();
|
||||||
*internal_config = config.clone();
|
*internal_config = Some(config.clone());
|
||||||
|
|
||||||
Ok(schedule)
|
Ok(schedule)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_metrics(state: tauri::State<'_, AppState>) -> Vec<ResidentMetrics> {
|
||||||
|
let schedule = state.schedule.lock().unwrap();
|
||||||
|
let tracker = state.tracker.lock().unwrap();
|
||||||
|
let config = state.config.lock().unwrap();
|
||||||
|
match config.as_ref() {
|
||||||
|
Some(c) => schedule.metrics(c, &tracker),
|
||||||
|
None => vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn export(state: tauri::State<'_, AppState>) -> Result<(), ExportError> {
|
fn export(state: tauri::State<'_, AppState>) -> Result<(), ExportError> {
|
||||||
let schedule = state.schedule.lock().unwrap();
|
let schedule = state.schedule.lock().unwrap();
|
||||||
let tracker = state.tracker.lock().unwrap();
|
let tracker = state.tracker.lock().unwrap();
|
||||||
let config = state.config.lock().unwrap();
|
let config = state.config.lock().unwrap();
|
||||||
|
let config = config.as_ref().ok_or(ExportError::NotGenerated)?;
|
||||||
|
|
||||||
schedule.export(FileType::Docx, &config, &tracker)?;
|
let metrics = schedule.metrics(config, &tracker);
|
||||||
schedule.export(FileType::Txt, &config, &tracker)?;
|
|
||||||
|
schedule.export(FileType::Docx, config, &metrics)?;
|
||||||
|
schedule.export(FileType::Txt, config, &metrics)?;
|
||||||
|
|
||||||
let log_dir = std::env::current_dir().unwrap_or(std::path::PathBuf::from("."));
|
let log_dir = std::env::current_dir().unwrap_or(std::path::PathBuf::from("."));
|
||||||
info!("Files exported at {}", log_dir.display());
|
info!("Files exported at {}", log_dir.display());
|
||||||
@@ -86,7 +100,7 @@ pub fn run() {
|
|||||||
.manage(AppState {
|
.manage(AppState {
|
||||||
schedule: Mutex::new(MonthlySchedule::new()),
|
schedule: Mutex::new(MonthlySchedule::new()),
|
||||||
tracker: Mutex::new(WorkloadTracker::default()),
|
tracker: Mutex::new(WorkloadTracker::default()),
|
||||||
config: Mutex::new(UserConfig::default()),
|
config: Mutex::new(None),
|
||||||
})
|
})
|
||||||
.plugin(
|
.plugin(
|
||||||
tauri_plugin_log::Builder::new()
|
tauri_plugin_log::Builder::new()
|
||||||
@@ -100,7 +114,7 @@ pub fn run() {
|
|||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.invoke_handler(tauri::generate_handler![generate, export])
|
.invoke_handler(tauri::generate_handler![generate, export, get_metrics])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("Error while running tauri application");
|
.expect("Error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ use serde::Serializer;
|
|||||||
#[derive(Deserialize, Debug, Clone, Default)]
|
#[derive(Deserialize, Debug, Clone, Default)]
|
||||||
pub struct MonthlySchedule(pub HashMap<Slot, ResidentId>);
|
pub struct MonthlySchedule(pub HashMap<Slot, ResidentId>);
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ResidentMetrics {
|
||||||
|
pub name: String,
|
||||||
|
pub total: u8,
|
||||||
|
pub open_first: u8,
|
||||||
|
pub open_second: u8,
|
||||||
|
pub closed: u8,
|
||||||
|
pub holiday: u8,
|
||||||
|
}
|
||||||
|
|
||||||
impl MonthlySchedule {
|
impl MonthlySchedule {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self::default()
|
Self::default()
|
||||||
@@ -88,7 +98,23 @@ impl MonthlySchedule {
|
|||||||
output
|
output
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn report(&self, config: &UserConfig, tracker: &WorkloadTracker) -> String {
|
pub fn metrics(&self, config: &UserConfig, tracker: &WorkloadTracker) -> Vec<ResidentMetrics> {
|
||||||
|
let mut residents: Vec<_> = config.residents.iter().collect();
|
||||||
|
residents.sort_by_key(|r| &r.name);
|
||||||
|
residents
|
||||||
|
.iter()
|
||||||
|
.map(|r| ResidentMetrics {
|
||||||
|
name: r.name.clone(),
|
||||||
|
total: tracker.current_workload(&r.id),
|
||||||
|
open_first: tracker.current_shift_type_workload(&r.id, ShiftType::OpenFirst),
|
||||||
|
open_second: tracker.current_shift_type_workload(&r.id, ShiftType::OpenSecond),
|
||||||
|
closed: tracker.current_shift_type_workload(&r.id, ShiftType::Closed),
|
||||||
|
holiday: tracker.current_holiday_workload(&r.id),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn report(&self, metrics: &[ResidentMetrics]) -> String {
|
||||||
let mut output = String::new();
|
let mut output = String::new();
|
||||||
output.push('\n');
|
output.push('\n');
|
||||||
output.push_str(&format!(
|
output.push_str(&format!(
|
||||||
@@ -98,23 +124,26 @@ impl MonthlySchedule {
|
|||||||
output.push_str("-".repeat(85).as_str());
|
output.push_str("-".repeat(85).as_str());
|
||||||
output.push('\n');
|
output.push('\n');
|
||||||
|
|
||||||
let mut residents: Vec<_> = config.residents.iter().collect();
|
for m in metrics {
|
||||||
residents.sort_by_key(|r| &r.name);
|
|
||||||
|
|
||||||
for r in residents {
|
|
||||||
let total = tracker.current_workload(&r.id);
|
|
||||||
let o1 = tracker.current_shift_type_workload(&r.id, ShiftType::OpenFirst);
|
|
||||||
let o2 = tracker.current_shift_type_workload(&r.id, ShiftType::OpenSecond);
|
|
||||||
let cl = tracker.current_shift_type_workload(&r.id, ShiftType::Closed);
|
|
||||||
let holiday = tracker.current_holiday_workload(&r.id);
|
|
||||||
|
|
||||||
output.push_str(&format!(
|
output.push_str(&format!(
|
||||||
"{:<15} | {:<6} | {:<10} | {:<10} | {:<7} | {:<10}\n",
|
"{:<15} | {:<6} | {:<10} | {:<10} | {:<7} | {:<10}\n",
|
||||||
r.name, total, o1, o2, cl, holiday
|
m.name, m.total, m.open_first, m.open_second, m.closed, m.holiday
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
output.push_str("-".repeat(85).as_str());
|
output.push_str("-".repeat(85).as_str());
|
||||||
output.push('\n');
|
output.push('\n');
|
||||||
|
|
||||||
|
let total: u8 = metrics.iter().map(|m| m.total).sum();
|
||||||
|
let o1: u8 = metrics.iter().map(|m| m.open_first).sum();
|
||||||
|
let o2: u8 = metrics.iter().map(|m| m.open_second).sum();
|
||||||
|
let cl: u8 = metrics.iter().map(|m| m.closed).sum();
|
||||||
|
let holiday: u8 = metrics.iter().map(|m| m.holiday).sum();
|
||||||
|
output.push_str(&format!(
|
||||||
|
"{:<15} | {:<6} | {:<10} | {:<10} | {:<7} | {:<10}\n",
|
||||||
|
"Σύνολο", total, o1, o2, cl, holiday
|
||||||
|
));
|
||||||
|
|
||||||
output
|
output
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ impl Scheduler {
|
|||||||
|
|
||||||
fn sort_residents(
|
fn sort_residents(
|
||||||
&self,
|
&self,
|
||||||
resident_ids: &mut Vec<ResidentId>,
|
resident_ids: &mut [ResidentId],
|
||||||
tracker: &WorkloadTracker,
|
tracker: &WorkloadTracker,
|
||||||
slot: Slot,
|
slot: Slot,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ mod integration_tests {
|
|||||||
let mut tracker = WorkloadTracker::default();
|
let mut tracker = WorkloadTracker::default();
|
||||||
|
|
||||||
let solved = scheduler.run(&mut schedule, &mut tracker)?;
|
let solved = scheduler.run(&mut schedule, &mut tracker)?;
|
||||||
println!("{}", schedule.report(&minimal_config, &tracker));
|
println!(
|
||||||
|
"{}",
|
||||||
|
schedule.report(&schedule.metrics(&minimal_config, &tracker))
|
||||||
|
);
|
||||||
assert!(solved);
|
assert!(solved);
|
||||||
validate_all_constraints(&schedule, &tracker, &minimal_config);
|
validate_all_constraints(&schedule, &tracker, &minimal_config);
|
||||||
|
|
||||||
@@ -50,7 +53,10 @@ mod integration_tests {
|
|||||||
let mut tracker = WorkloadTracker::default();
|
let mut tracker = WorkloadTracker::default();
|
||||||
|
|
||||||
let solved = scheduler.run(&mut schedule, &mut tracker)?;
|
let solved = scheduler.run(&mut schedule, &mut tracker)?;
|
||||||
println!("{}", schedule.report(&maximal_config, &tracker));
|
println!(
|
||||||
|
"{}",
|
||||||
|
schedule.report(&schedule.metrics(&maximal_config, &tracker))
|
||||||
|
);
|
||||||
assert!(solved);
|
assert!(solved);
|
||||||
validate_all_constraints(&schedule, &tracker, &maximal_config);
|
validate_all_constraints(&schedule, &tracker, &maximal_config);
|
||||||
|
|
||||||
@@ -68,7 +74,10 @@ mod integration_tests {
|
|||||||
let mut tracker = WorkloadTracker::default();
|
let mut tracker = WorkloadTracker::default();
|
||||||
|
|
||||||
let solved = scheduler.run(&mut schedule, &mut tracker)?;
|
let solved = scheduler.run(&mut schedule, &mut tracker)?;
|
||||||
println!("{}", schedule.report(&manual_shifts_heavy_config, &tracker));
|
println!(
|
||||||
|
"{}",
|
||||||
|
schedule.report(&schedule.metrics(&manual_shifts_heavy_config, &tracker))
|
||||||
|
);
|
||||||
assert!(solved);
|
assert!(solved);
|
||||||
validate_all_constraints(&schedule, &tracker, &manual_shifts_heavy_config);
|
validate_all_constraints(&schedule, &tracker, &manual_shifts_heavy_config);
|
||||||
|
|
||||||
@@ -86,7 +95,10 @@ mod integration_tests {
|
|||||||
let mut tracker = WorkloadTracker::default();
|
let mut tracker = WorkloadTracker::default();
|
||||||
|
|
||||||
let solved = scheduler.run(&mut schedule, &mut tracker)?;
|
let solved = scheduler.run(&mut schedule, &mut tracker)?;
|
||||||
println!("{}", schedule.report(&complex_config, &tracker));
|
println!(
|
||||||
|
"{}",
|
||||||
|
schedule.report(&schedule.metrics(&complex_config, &tracker))
|
||||||
|
);
|
||||||
assert!(solved);
|
assert!(solved);
|
||||||
validate_all_constraints(&schedule, &tracker, &complex_config);
|
validate_all_constraints(&schedule, &tracker, &complex_config);
|
||||||
|
|
||||||
@@ -104,7 +116,10 @@ mod integration_tests {
|
|||||||
let mut tracker = WorkloadTracker::default();
|
let mut tracker = WorkloadTracker::default();
|
||||||
|
|
||||||
let solved = scheduler.run(&mut schedule, &mut tracker)?;
|
let solved = scheduler.run(&mut schedule, &mut tracker)?;
|
||||||
println!("{}", schedule.report(&hard_config, &tracker));
|
println!(
|
||||||
|
"{}",
|
||||||
|
schedule.report(&schedule.metrics(&hard_config, &tracker))
|
||||||
|
);
|
||||||
assert!(solved);
|
assert!(solved);
|
||||||
validate_all_constraints(&schedule, &tracker, &hard_config);
|
validate_all_constraints(&schedule, &tracker, &hard_config);
|
||||||
|
|
||||||
|
|||||||
@@ -118,4 +118,9 @@
|
|||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
|
input[type="number"]::-webkit-inner-spin-button,
|
||||||
|
input[type="number"]::-webkit-outer-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,38 +2,38 @@
|
|||||||
import Basic from "./components/configurations/basic.svelte";
|
import Basic from "./components/configurations/basic.svelte";
|
||||||
import Residents from "./components/configurations/residents.svelte";
|
import Residents from "./components/configurations/residents.svelte";
|
||||||
import Advanced from "./components/configurations/advanced.svelte";
|
import Advanced from "./components/configurations/advanced.svelte";
|
||||||
import Preview from "./components/schedule/preview.svelte";
|
import { EngineStatus, rota, steps } from "./state.svelte.js";
|
||||||
|
|
||||||
import { rota, steps } from "./state.svelte.js";
|
|
||||||
import Generate from "./components/schedule/generate.svelte";
|
import Generate from "./components/schedule/generate.svelte";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main
|
<main
|
||||||
class="grid h-screen w-full grid-cols-5 overflow-hidden bg-zinc-200/50 font-sans tracking-tight antialiased"
|
class="bg-zinc-200/ grid h-screen w-full grid-cols-5 overflow-hidden font-sans tracking-tight antialiased"
|
||||||
>
|
>
|
||||||
<aside
|
<aside
|
||||||
class="col-span-1 flex flex-col border-r border-zinc-200 bg-zinc-50/50 font-sans antialiased"
|
class="col-span-1 flex flex-col border-r border-zinc-200 bg-zinc-50/50 font-sans antialiased"
|
||||||
>
|
>
|
||||||
<div class="flex justify-center border p-3 font-sans">
|
<div class="flex justify-center p-2 font-sans">
|
||||||
<h1 class="text-xl font-black tracking-tight uppercase">Rota Scheduler</h1>
|
<h1 class="text-xl font-black tracking-tighter text-slate-900 uppercase">
|
||||||
|
Rota <span class="text-emerald-600">Scheduler</span>
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="h-px w-full bg-zinc-200"></div>
|
||||||
|
|
||||||
<nav class="relative flex-1 p-6">
|
<nav class="relative flex flex-col p-4">
|
||||||
<div class="absolute top-10 bottom-10 left-9.75 w-0.5 bg-zinc-200"></div>
|
<div class="flex flex-col gap-2">
|
||||||
|
|
||||||
<div class="relative flex h-full flex-col justify-between">
|
|
||||||
{#each steps as step}
|
{#each steps as step}
|
||||||
<button
|
<button
|
||||||
onclick={() => (rota.currentStep = step.id)}
|
onclick={() => (rota.currentStep = step.id)}
|
||||||
class="group relative z-10 flex items-center gap-4 py-4 transition-all"
|
class="group relative z-10 flex items-center gap-4 rounded-xl p-4 transition-all
|
||||||
|
{rota.currentStep === step.id ? 'bg-zinc-200/80 shadow-inner' : 'hover:bg-zinc-100/50'}"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex size-8 items-center justify-center rounded-full border-2 transition-all duration-300
|
class="flex size-8 items-center justify-center rounded-lg transition-all duration-300
|
||||||
{rota.currentStep === step.id
|
{rota.currentStep === step.id
|
||||||
? 'bg-slate-800 text-white'
|
? 'bg-slate-800 text-white shadow-md'
|
||||||
: rota.currentStep > step.id
|
: rota.currentStep > step.id
|
||||||
? 'bg-emerald-600 text-white'
|
? 'bg-emerald-600 text-white'
|
||||||
: 'bg-white text-zinc-400'}"
|
: 'border border-zinc-200 bg-white text-zinc-400'}"
|
||||||
>
|
>
|
||||||
{#if rota.currentStep > step.id}
|
{#if rota.currentStep > step.id}
|
||||||
<svg
|
<svg
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
<div class="flex flex-col items-start">
|
<div class="flex flex-col items-start">
|
||||||
<span
|
<span
|
||||||
class="text-[10px] font-bold tracking-widest uppercase
|
class="text-[10px] font-bold tracking-widest uppercase
|
||||||
{rota.currentStep === step.id ? 'text-black-800' : 'text-zinc-400'}"
|
{rota.currentStep === step.id ? 'text-zinc-600' : 'text-zinc-400'}"
|
||||||
>
|
>
|
||||||
ΒΗΜΑ {step.id}
|
ΒΗΜΑ {step.id}
|
||||||
</span>
|
</span>
|
||||||
@@ -70,19 +70,130 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="border-t border-zinc-200 bg-white p-6">
|
{#if rota.metrics.length > 0}
|
||||||
<div class="rounded-xl border border-zinc-100 bg-zinc-50 p-4">
|
<div class="h-px w-full bg-zinc-200"></div>
|
||||||
<div class="mb-2 flex items-center justify-between">
|
<div class="flex flex-1 flex-col py-4">
|
||||||
<span class="text-[10px] font-bold text-zinc-500 uppercase">ΟΛΟΚΛΗΡΩΣΗ</span>
|
<p class="px-6 pb-2 text-[10px] font-black tracking-widest text-zinc-400 uppercase">
|
||||||
<span class="text-[10px] font-bold text-zinc-500"
|
Δικαιωσυνη
|
||||||
>{(((rota.currentStep - 1) / (steps.length - 1)) * 100).toFixed(0)}%</span
|
</p>
|
||||||
|
<div class="w-full overflow-hidden px-2 text-[10px]">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-zinc-300 bg-zinc-50">
|
||||||
|
<th
|
||||||
|
class="border-r border-zinc-300 px-2 py-1 text-left font-bold text-zinc-500 uppercase last:border-r-0"
|
||||||
|
>Ειδικευομενος</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="border-r border-zinc-300 px-1 py-1 text-center font-bold text-zinc-500 last:border-r-0"
|
||||||
|
>ΣΥΝ</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="border-r border-zinc-300 px-1 py-1 text-center font-bold text-zinc-500 last:border-r-0"
|
||||||
|
>Α1</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="border-r border-zinc-300 px-1 py-1 text-center font-bold text-zinc-500 last:border-r-0"
|
||||||
|
>Α2</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="border-r border-zinc-300 px-1 py-1 text-center font-bold text-zinc-500 last:border-r-0"
|
||||||
|
>Κ</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="border-r border-zinc-300 px-1 py-1 text-center font-bold text-zinc-500 last:border-r-0"
|
||||||
|
>ΣΚ/Α</th
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each rota.metrics as m}
|
||||||
|
<tr class="border-b border-zinc-200 last:border-0 hover:bg-zinc-50">
|
||||||
|
<td
|
||||||
|
class="border-r border-zinc-200 px-2 py-1 font-medium text-zinc-700 last:border-r-0"
|
||||||
|
>{m.name}</td
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
class="border-r border-zinc-200 px-1 py-1 text-center font-bold text-zinc-800 last:border-r-0"
|
||||||
|
>{m.total}</td
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
class="border-r border-zinc-200 px-1 py-1 text-center text-zinc-500 last:border-r-0"
|
||||||
|
>{m.open_first}</td
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
class="border-r border-zinc-200 px-1 py-1 text-center text-zinc-500 last:border-r-0"
|
||||||
|
>{m.open_second}</td
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
class="border-r border-zinc-200 px-1 py-1 text-center text-zinc-500 last:border-r-0"
|
||||||
|
>{m.closed}</td
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
class="border-r border-zinc-200 px-1 py-1 text-center text-zinc-500 last:border-r-0"
|
||||||
|
>{m.holiday}</td
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="border-t border-zinc-300 bg-zinc-50">
|
||||||
|
<th
|
||||||
|
class="border-r border-zinc-300 px-2 py-1 text-left font-bold text-zinc-500 uppercase last:border-r-0"
|
||||||
|
>Συνολο</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="border-r border-zinc-300 px-1 py-1 text-center font-bold text-zinc-500 last:border-r-0"
|
||||||
|
>{rota.metrics.reduce((s, m) => s + m.total, 0)}</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="border-r border-zinc-300 px-1 py-1 text-center font-bold text-zinc-500 last:border-r-0"
|
||||||
|
>{rota.metrics.reduce((s, m) => s + m.open_first, 0)}</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="border-r border-zinc-300 px-1 py-1 text-center font-bold text-zinc-500 last:border-r-0"
|
||||||
|
>{rota.metrics.reduce((s, m) => s + m.open_second, 0)}</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="border-r border-zinc-300 px-1 py-1 text-center font-bold text-zinc-500 last:border-r-0"
|
||||||
|
>{rota.metrics.reduce((s, m) => s + m.closed, 0)}</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="border-r border-zinc-300 px-1 py-1 text-center font-bold text-zinc-500 last:border-r-0"
|
||||||
|
>{rota.metrics.reduce((s, m) => s + m.holiday, 0)}</th
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="h-px w-full bg-zinc-200"></div>
|
||||||
|
<div class="space-y-4 p-4">
|
||||||
|
<div
|
||||||
|
class="border-l-2 py-2 pl-3 transition-colors
|
||||||
|
{rota.engineStatus === EngineStatus.Running
|
||||||
|
? 'border-amber-400'
|
||||||
|
: rota.engineStatus === EngineStatus.Success
|
||||||
|
? 'border-emerald-500'
|
||||||
|
: rota.engineStatus === EngineStatus.Error
|
||||||
|
? 'border-red-500'
|
||||||
|
: 'border-zinc-300'}"
|
||||||
|
>
|
||||||
|
<div class="mb-2 flex items-center gap-2">
|
||||||
|
<span class="text-[10px] font-black tracking-widest text-zinc-400 uppercase"
|
||||||
|
>ΚΑΤΑΣΤΑΣΗ ΜΗΧΑΝΗΣ</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-1.5 w-full overflow-hidden rounded-full bg-zinc-200">
|
|
||||||
<div
|
<div class="flex flex-col gap-1">
|
||||||
class="h-full bg-emerald-600 transition-all duration-500"
|
<span class="text-[11px] font-bold text-zinc-800 uppercase">
|
||||||
style="width: {(((rota.currentStep - 1) / (steps.length - 1)) * 100).toFixed(0)}%"
|
{rota.engineStatus}
|
||||||
></div>
|
</span>
|
||||||
|
<p class="text-[10px] leading-tight font-medium text-zinc-500">
|
||||||
|
{rota.lastMessage}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,8 +208,6 @@
|
|||||||
{:else if rota.currentStep === 3}
|
{:else if rota.currentStep === 3}
|
||||||
<Advanced />
|
<Advanced />
|
||||||
{:else if rota.currentStep === 4}
|
{:else if rota.currentStep === 4}
|
||||||
<Preview />
|
|
||||||
{:else if rota.currentStep === 5}
|
|
||||||
<Generate />
|
<Generate />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,73 +29,57 @@
|
|||||||
<p class="text-sm text-zinc-500">{steps[rota.currentStep - 1].description}</p>
|
<p class="text-sm text-zinc-500">{steps[rota.currentStep - 1].description}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if rota.forbiddenPairs.length > 0}
|
<div class="space-y-2">
|
||||||
<div class="space-y-3">
|
{#each rota.forbiddenPairs as pair, i (pair)}
|
||||||
{#each rota.forbiddenPairs as pair, i (pair)}
|
<div class="flex items-center gap-2">
|
||||||
<div
|
<select
|
||||||
class="group flex items-center gap-4 rounded-2xl border border-zinc-200 bg-white p-4 transition-all hover:border-blue-200 hover:bg-white"
|
bind:value={pair.id1}
|
||||||
|
class="flex-1 appearance-none rounded-lg border border-zinc-200 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 transition-all outline-none focus:border-zinc-400 focus:ring-4 focus:ring-zinc-100"
|
||||||
>
|
>
|
||||||
<div class="flex flex-1 flex-col gap-1">
|
{#each rota.residents as r}
|
||||||
<p class="ml-1 text-[10px] font-black tracking-widest text-zinc-500 uppercase">
|
<option value={r.id}>{r.name || "Ανώνυμος"}</option>
|
||||||
Resident A
|
{/each}
|
||||||
</p>
|
</select>
|
||||||
<select
|
|
||||||
bind:value={pair.id1}
|
|
||||||
class="w-full rounded-xl border border-zinc-100 bg-zinc-50 px-3 py-2 text-sm font-semibold text-zinc-700 outline-none focus:ring-2"
|
|
||||||
>
|
|
||||||
{#each rota.residents as r}
|
|
||||||
<option value={r.id}>{r.name || "Unnamed Resident"}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col gap-1">
|
<span class="text-sm font-bold text-zinc-400">×</span>
|
||||||
<p class="ml-1 text-[10px] font-black tracking-widest text-zinc-500 uppercase">
|
|
||||||
Resident B
|
|
||||||
</p>
|
|
||||||
<select
|
|
||||||
bind:value={pair.id2}
|
|
||||||
class="w-full rounded-xl border border-zinc-100 bg-zinc-50 px-3 py-2 text-sm font-semibold text-zinc-700 outline-none focus:ring-2"
|
|
||||||
>
|
|
||||||
{#each rota.residents as r}
|
|
||||||
{#if r.id !== pair.id1}
|
|
||||||
<option value={r.id}>{r.name || "Unnamed Resident"}</option>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<select
|
||||||
onclick={() => rota.removeForbiddenPair(i)}
|
bind:value={pair.id2}
|
||||||
class="mt-5 rounded-lg p-2 text-zinc-300 transition-colors"
|
class="flex-1 appearance-none rounded-lg border border-zinc-200 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 transition-all outline-none focus:border-zinc-400 focus:ring-4 focus:ring-zinc-100"
|
||||||
aria-label="Remove forbidden pair"
|
>
|
||||||
|
{#each rota.residents as r}
|
||||||
|
{#if r.id !== pair.id1}
|
||||||
|
<option value={r.id}>{r.name || "Ανώνυμος"}</option>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={() => rota.removeForbiddenPair(i)}
|
||||||
|
class="rounded-lg p-1.5 text-zinc-400 transition-colors hover:text-red-500 active:scale-90"
|
||||||
|
aria-label="Remove forbidden pair"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
>
|
>
|
||||||
<svg
|
<path d="M3 6h18" /><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /><path
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"
|
||||||
width="18"
|
/><line x1="10" x2="10" y1="11" y2="17" /><line x1="14" x2="14" y1="11" y2="17" />
|
||||||
height="18"
|
</svg>
|
||||||
viewBox="0 0 24 24"
|
</button>
|
||||||
fill="none"
|
</div>
|
||||||
stroke="currentColor"
|
{/each}
|
||||||
stroke-width="2.5"
|
</div>
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
><path d="M3 6h18" /><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /><path
|
|
||||||
d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"
|
|
||||||
/><line x1="10" x2="10" y1="11" y2="17" /><line
|
|
||||||
x1="14"
|
|
||||||
x2="14"
|
|
||||||
y1="11"
|
|
||||||
y2="17"
|
|
||||||
/></svg
|
|
||||||
>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="mt-8 flex justify-center">
|
<div class="mt-6 flex justify-center">
|
||||||
<Button
|
<Button
|
||||||
onclick={() => rota.addForbiddenPair()}
|
onclick={() => rota.addForbiddenPair()}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<p class="ml-1 text-[10px] font-black tracking-widest text-zinc-500 uppercase">MONTH</p>
|
<p class="ml-1 text-[10px] font-black tracking-widest text-zinc-500 uppercase">ΜΗΝΑΣ</p>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<select
|
<select
|
||||||
bind:value={rota.selectedMonth}
|
bind:value={rota.selectedMonth}
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<p class="ml-1 text-[10px] font-black tracking-widest text-zinc-500 uppercase">YEAR</p>
|
<p class="ml-1 text-[10px] font-black tracking-widest text-zinc-500 uppercase">ΧΡΟΝΟΣ</p>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<select
|
<select
|
||||||
bind:value={rota.selectedYear}
|
bind:value={rota.selectedYear}
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 space-y-2">
|
<div class="mt-8 space-y-2">
|
||||||
<p class="ml-1 text-[10px] font-black tracking-widest text-zinc-500 uppercase">HOLIDAYS</p>
|
<p class="ml-1 text-[10px] font-black tracking-widest text-zinc-500 uppercase">ΑΡΓΙΕΣ</p>
|
||||||
|
|
||||||
<Popover.Root>
|
<Popover.Root>
|
||||||
<Popover.Trigger>
|
<Popover.Trigger>
|
||||||
|
|||||||
@@ -33,17 +33,17 @@
|
|||||||
</header>
|
</header>
|
||||||
{#each rota.residents as resident, residentIndex (resident.id)}
|
{#each rota.residents as resident, residentIndex (resident.id)}
|
||||||
<div
|
<div
|
||||||
class="group relative flex flex-col gap-6 rounded-2xl border border-zinc-200 bg-zinc-50 p-6 transition-all hover:border-blue-200 hover:bg-white"
|
class="group relative flex flex-col gap-6 rounded-2xl border border-zinc-200 bg-zinc-50 p-6 transition-all hover:border-zinc-300 hover:bg-white"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="text-[10px] font-black tracking-widest text-zinc-500 uppercase">
|
<span class="text-[10px] font-black tracking-widest text-zinc-500 uppercase">
|
||||||
Resident {residentIndex + 1}
|
ΕΙΔΙΚΕΥΟΜΕΝΟΣ {residentIndex + 1}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onclick={() => rota.removeResident(resident.id)}
|
onclick={() => rota.removeResident(resident.id)}
|
||||||
class="text-zinc-500 transition-colors"
|
class="text-zinc-400 transition-colors hover:text-red-500 active:scale-90 active:text-red-700"
|
||||||
aria-label="Remove resident"
|
aria-label="Remove resident"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -65,23 +65,23 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-12 items-end gap-6">
|
<div class="grid grid-cols-12 items-end gap-6">
|
||||||
<div class="col-span-4 space-y-2">
|
<div class="col-span-4 space-y-2">
|
||||||
<p class="ml-1 text-xs font-bold text-zinc-500 uppercase">Full Name</p>
|
<p class="ml-1 text-xs font-bold text-zinc-500 uppercase">ΟΝΟΜΑ</p>
|
||||||
<input
|
<input
|
||||||
bind:value={resident.name}
|
bind:value={resident.name}
|
||||||
placeholder="π.χ. Τάκης Τσουκαλάς"
|
placeholder="π.χ. Τάκης Τσουκαλάς"
|
||||||
class="w-full rounded-xl border border-zinc-200 bg-white px-4 py-2.5 text-sm outline-none hover:border-blue-200 hover:bg-white"
|
class="w-full rounded-xl border border-zinc-200 bg-white px-4 py-2.5 text-sm outline-none hover:border-zinc-300 hover:bg-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-4 space-y-2">
|
<div class="col-span-4 space-y-2">
|
||||||
<p class="ml-1 text-xs font-bold text-zinc-500 uppercase">Negative Shifts</p>
|
<p class="ml-1 text-xs font-bold text-zinc-500 uppercase">ΑΡΝΗΤΙΚΕΣ ΕΦΗΜΕΡΙΕΣ</p>
|
||||||
<Popover.Root>
|
<Popover.Root>
|
||||||
<Popover.Trigger>
|
<Popover.Trigger>
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<Button
|
<Button
|
||||||
{...props}
|
{...props}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="w-full justify-start rounded-xl border-zinc-200 bg-white px-4 py-5 font-normal hover:border-blue-200 hover:bg-white"
|
class="w-full justify-start rounded-xl border-zinc-200 bg-white px-4 py-5 font-normal hover:border-zinc-300 hover:bg-white"
|
||||||
>
|
>
|
||||||
<span class="mr-2 text-zinc-500"
|
<span class="mr-2 text-zinc-500"
|
||||||
><svg
|
><svg
|
||||||
@@ -190,14 +190,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-4 space-y-2">
|
<div class="col-span-4 space-y-2">
|
||||||
<p class="ml-1 text-xs font-bold text-zinc-500 uppercase">Manual Shifts</p>
|
<p class="ml-1 text-xs font-bold text-zinc-500 uppercase">ΑΝΑΓΚΑΣΤΙΚΕΣ ΕΦΗΜΕΡΙΕΣ</p>
|
||||||
<Popover.Root>
|
<Popover.Root>
|
||||||
<Popover.Trigger>
|
<Popover.Trigger>
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<Button
|
<Button
|
||||||
{...props}
|
{...props}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="w-full justify-start rounded-xl border-zinc-200 bg-white px-4 py-5 font-normal hover:border-blue-200 hover:bg-white"
|
class="w-full justify-start rounded-xl border-zinc-200 bg-white px-4 py-5 font-normal hover:border-zinc-300 hover:bg-white"
|
||||||
>
|
>
|
||||||
<span class="mr-2 text-zinc-500"
|
<span class="mr-2 text-zinc-500"
|
||||||
><svg
|
><svg
|
||||||
@@ -307,22 +307,23 @@
|
|||||||
<div
|
<div
|
||||||
class="grid grid-cols-3 divide-x divide-zinc-200 rounded-xl border border-zinc-200 bg-white py-3"
|
class="grid grid-cols-3 divide-x divide-zinc-200 rounded-xl border border-zinc-200 bg-white py-3"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col items-center space-y-2 px-4">
|
<div class="flex flex-col items-center gap-2 px-4">
|
||||||
<p class="text-[10px] font-black tracking-widest text-zinc-500 uppercase">Max Shifts</p>
|
<p class="text-[10px] font-black tracking-widest text-zinc-500 uppercase">
|
||||||
<div class="flex items-center gap-2">
|
ΜΕΓΙΣΤΟΣ ΦΟΡΤΟΣ
|
||||||
<input
|
</p>
|
||||||
type="number"
|
<input
|
||||||
bind:value={resident.maxShifts}
|
type="number"
|
||||||
placeholder="-"
|
bind:value={resident.maxShifts}
|
||||||
class="w-16 rounded-lg border border-zinc-100 bg-zinc-50 py-1 text-center text-sm font-bold text-zinc-700 outline-none placeholder:text-zinc-300"
|
class="w-8 rounded border border-zinc-200 bg-white py-1 text-center text-sm font-bold text-zinc-700 transition-all outline-none hover:border-zinc-300 focus:border-zinc-400 focus:ring-4 focus:ring-zinc-100"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col items-center space-y-2 px-4">
|
<div class="flex flex-col items-center space-y-2 px-4">
|
||||||
<p class="text-[10px] font-black tracking-widest text-zinc-500 uppercase">Shift Types</p>
|
<p class="text-[10px] font-black tracking-widest text-zinc-500 uppercase">
|
||||||
|
ΤΥΠΟΙ ΕΦΗΜΕΡΙΩΝ
|
||||||
|
</p>
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
{#each ["Closed", "OpenFirst", "OpenSecond"] as type}
|
{#each [["Closed", "Κλειστή"], ["OpenFirst", "Ανοιχτή 1"], ["OpenSecond", "Ανοιχτή 2"]] as [type, label]}
|
||||||
{@const active = resident.allowedTypes.includes(type)}
|
{@const active = resident.allowedTypes.includes(type)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -338,29 +339,27 @@
|
|||||||
? 'border-zinc-800 bg-zinc-800 text-white'
|
? 'border-zinc-800 bg-zinc-800 text-white'
|
||||||
: 'border-zinc-200 bg-white text-zinc-500 hover:bg-zinc-50 hover:text-zinc-600'}"
|
: 'border-zinc-200 bg-white text-zinc-500 hover:bg-zinc-50 hover:text-zinc-600'}"
|
||||||
>
|
>
|
||||||
{type.replace("OpenAs", "")}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col items-center space-y-2 px-4">
|
<div class="flex flex-col items-center gap-2 px-4">
|
||||||
<p class="text-[10px] font-black tracking-widest text-zinc-500 uppercase">
|
<p class="text-[10px] font-black tracking-widest text-zinc-500 uppercase">
|
||||||
Reduced Workload
|
ΜΕΙΩΣΗ ΦΟΡΤΟΥ
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
|
role="switch"
|
||||||
|
aria-checked={resident.reducedLoad}
|
||||||
onclick={() => (resident.reducedLoad = !resident.reducedLoad)}
|
onclick={() => (resident.reducedLoad = !resident.reducedLoad)}
|
||||||
class="flex items-center gap-2 rounded-lg border px-3 py-1 transition-all hover:border-blue-200 hover:bg-white
|
class="relative h-5 w-9 shrink-0 rounded-full transition-colors duration-200
|
||||||
{resident.reducedLoad
|
{resident.reducedLoad ? 'bg-green-500' : 'bg-zinc-200'}"
|
||||||
? 'border-green-200 bg-green-50 text-green-700'
|
|
||||||
: 'border-zinc-200 bg-white text-zinc-500'}"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="size-2 rounded-full {resident.reducedLoad
|
class="absolute top-0.5 left-0.5 size-4 rounded-full bg-white shadow transition-transform duration-200
|
||||||
? 'animate-pulse bg-green-500'
|
{resident.reducedLoad ? 'translate-x-4' : 'translate-x-0'}"
|
||||||
: 'bg-zinc-300'}"
|
|
||||||
></div>
|
></div>
|
||||||
<span class="text-[10px] font-bold uppercase">-1</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -371,7 +370,8 @@
|
|||||||
<Button
|
<Button
|
||||||
onclick={() => rota.addResident()}
|
onclick={() => rota.addResident()}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="border-zinc-200 bg-white text-zinc-600 shadow-sm transition-all hover:bg-zinc-50 hover:text-zinc-900 active:scale-95"
|
disabled={rota.residents.some((r) => !r.name.trim())}
|
||||||
|
class="border-zinc-200 bg-white text-zinc-600 shadow-sm transition-all hover:bg-zinc-50 hover:text-zinc-900 active:scale-95 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
>
|
>
|
||||||
Προσθήκη Ειδικευόμενου
|
Προσθήκη Ειδικευόμενου
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from "$lib/components/ui/button/index.js";
|
import { Button } from "$lib/components/ui/button/index.js";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { type MonthlyScheduleDTO, rota, steps, type ShiftPosition } from "../../state.svelte.js";
|
import {
|
||||||
|
EngineStatus,
|
||||||
|
type MonthlyScheduleDTO,
|
||||||
|
type ResidentMetrics,
|
||||||
|
rota,
|
||||||
|
steps,
|
||||||
|
type ShiftPosition
|
||||||
|
} from "../../state.svelte.js";
|
||||||
|
|
||||||
function getResidentName(day: number, pos: ShiftPosition) {
|
function getResidentName(day: number, pos: ShiftPosition) {
|
||||||
const residentId = rota.solution[`${day}-${pos}`];
|
const residentId = rota.solution[`${day}-${pos}`];
|
||||||
@@ -41,23 +48,24 @@
|
|||||||
|
|
||||||
async function generate() {
|
async function generate() {
|
||||||
let config = rota.toDTO();
|
let config = rota.toDTO();
|
||||||
console.log(config);
|
rota.engineStatus = EngineStatus.Running;
|
||||||
|
rota.lastMessage = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let schedule = await invoke<MonthlyScheduleDTO>("generate", { config });
|
rota.solution = await invoke<MonthlyScheduleDTO>("generate", { config });
|
||||||
console.log(schedule);
|
rota.metrics = await invoke<ResidentMetrics[]>("get_metrics");
|
||||||
rota.solution = schedule;
|
rota.engineStatus = EngineStatus.Success;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const { kind, details } = error as AppError;
|
const { kind, details } = error as AppError;
|
||||||
|
rota.engineStatus = EngineStatus.Error;
|
||||||
|
rota.lastMessage = details;
|
||||||
console.error(`[${kind}] - ${details}`);
|
console.error(`[${kind}] - ${details}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function export_file() {
|
async function export_file() {
|
||||||
let schedule = rota.solution;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await invoke("export", { schedule });
|
await invoke("export");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const { kind, details } = error as AppError;
|
const { kind, details } = error as AppError;
|
||||||
console.error(`[${kind}] - ${details}`);
|
console.error(`[${kind}] - ${details}`);
|
||||||
@@ -91,10 +99,10 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div class="grid auto-rows-fr grid-cols-7 gap-px bg-zinc-200">
|
<div class="grid auto-rows-fr grid-cols-7 gap-px bg-zinc-200">
|
||||||
{#each rota.emptySlots as _}<div class="bg-zinc-50/30"></div>{/each}
|
{#each rota.emptySlots as _}<div class="min-h-25 bg-zinc-100/60"></div>{/each}
|
||||||
{#each rota.daysArray as day (day)}
|
{#each rota.daysArray as day (day)}
|
||||||
{@const slotCount = getRequiredSlots(day)}
|
{@const slotCount = getRequiredSlots(day)}
|
||||||
<div class="group min-h-25 bg-white p-2 transition-all hover:bg-blue-50/30">
|
<div class="group min-h-25 bg-white p-2 transition-all hover:bg-teal-50/30">
|
||||||
<div class="mb-2 flex items-center justify-between">
|
<div class="mb-2 flex items-center justify-between">
|
||||||
<span class="text-xs font-black text-zinc-500">{day}</span>
|
<span class="text-xs font-black text-zinc-500">{day}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,7 +111,7 @@
|
|||||||
{#if slotCount > 0}
|
{#if slotCount > 0}
|
||||||
<button
|
<button
|
||||||
onclick={() => "handleCellClick(day, 1)"}
|
onclick={() => "handleCellClick(day, 1)"}
|
||||||
class="w-full overflow-hidden rounded border border-pink-200 bg-pink-50 px-1.5 py-1 text-left text-[10px] font-bold text-pink-600 transition-colors hover:bg-pink-100"
|
class="w-full overflow-hidden rounded border border-rose-200 bg-rose-50 px-1.5 py-1 text-left text-[10px] font-bold text-rose-700 transition-colors hover:bg-rose-100"
|
||||||
>
|
>
|
||||||
{getResidentName(day, "First")}
|
{getResidentName(day, "First")}
|
||||||
</button>
|
</button>
|
||||||
@@ -111,7 +119,7 @@
|
|||||||
{#if slotCount > 1}
|
{#if slotCount > 1}
|
||||||
<button
|
<button
|
||||||
onclick={() => "handleCellClick(day, 2)"}
|
onclick={() => "handleCellClick(day, 2)"}
|
||||||
class="w-full overflow-hidden rounded border border-emerald-200 bg-emerald-50 px-1.5 py-1 text-left text-[10px] font-bold text-emerald-600 transition-colors hover:bg-emerald-100"
|
class="w-full overflow-hidden rounded border border-emerald-200 bg-emerald-50 px-1.5 py-1 text-left text-[10px] font-bold text-emerald-700 transition-colors hover:bg-emerald-100"
|
||||||
>
|
>
|
||||||
{getResidentName(day, "Second")}
|
{getResidentName(day, "Second")}
|
||||||
</button>
|
</button>
|
||||||
@@ -119,6 +127,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#each Array.from( { length: (7 - ((rota.emptySlots.length + rota.daysArray.length) % 7)) % 7 } ) as _}
|
||||||
|
<div class="min-h-25 bg-zinc-100/60"></div>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Button } from "$lib/components/ui/button/index.js";
|
|
||||||
import { rota, steps } from "../../state.svelte.js";
|
|
||||||
|
|
||||||
function getResidentName(day: number, slot: number) {
|
|
||||||
const assignedResidents = rota.residents.filter((resident) =>
|
|
||||||
resident.manualShifts.some(
|
|
||||||
(shift) =>
|
|
||||||
shift.day === day &&
|
|
||||||
shift.month === rota.selectedMonth &&
|
|
||||||
shift.year === rota.selectedYear
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const resident = assignedResidents[slot - 1];
|
|
||||||
return resident ? resident.name : "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2 slots in odd days, 1 slot in even days
|
|
||||||
function getRequiredSlots(day: number) {
|
|
||||||
return day % 2 === 0 ? 1 : 2;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="mb-6 flex items-center justify-between">
|
|
||||||
<h2 class="text-2xl font-bold text-zinc-800">{steps[rota.currentStep - 1].title}</h2>
|
|
||||||
<div class="justify-end">
|
|
||||||
<Button
|
|
||||||
onclick={() => (rota.currentStep -= 1)}
|
|
||||||
variant="outline"
|
|
||||||
class="border-zinc-200 bg-white text-zinc-600 shadow-sm transition-all hover:bg-zinc-50 hover:text-zinc-900 active:scale-95"
|
|
||||||
>
|
|
||||||
Προηγούμενο
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onclick={() => (rota.currentStep += 1)}
|
|
||||||
variant="outline"
|
|
||||||
class="border-zinc-200 bg-white text-zinc-600 shadow-sm transition-all hover:bg-zinc-50 hover:text-zinc-900 active:scale-95"
|
|
||||||
>
|
|
||||||
Επόμενο
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<header class="mb-2">
|
|
||||||
<p class="text-sm text-zinc-500">{steps[rota.currentStep - 1].description}</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="overflow-hidden rounded-xl border border-zinc-200 bg-white">
|
|
||||||
<div class="grid grid-cols-7 border-b border-zinc-200 bg-zinc-50/50">
|
|
||||||
{#each ["ΔΕΥΤΕΡΑ", "ΤΡΙΤΗ", "ΤΕΤΑΡΤΗ", "ΠΕΜΠΤΗ", "ΠΑΡΑΣΚΕΥΗ", "ΣΑΒΒΑΤΟ", "ΚΥΡΙΑΚΗ"] as dayName}
|
|
||||||
<div class="py-2 text-center text-[10px] font-bold tracking-widest text-zinc-500 uppercase">
|
|
||||||
{dayName}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div class="grid auto-rows-fr grid-cols-7 gap-px bg-zinc-200">
|
|
||||||
{#each rota.emptySlots as _}<div class="bg-zinc-50/30"></div>{/each}
|
|
||||||
{#each rota.daysArray as day (day)}
|
|
||||||
{@const slotCount = getRequiredSlots(day)}
|
|
||||||
<div class="group min-h-25 bg-white p-2 transition-all hover:bg-blue-50/30">
|
|
||||||
<div class="mb-2 flex items-center justify-between">
|
|
||||||
<span class="text-xs font-black text-zinc-500">{day}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-1">
|
|
||||||
{#if slotCount > 0}
|
|
||||||
<button
|
|
||||||
onclick={() => "handleCellClick(day, 1)"}
|
|
||||||
class="w-full overflow-hidden rounded border border-pink-200 bg-pink-50 px-1.5 py-1 text-left text-[10px] font-bold text-pink-600 transition-colors hover:bg-pink-100"
|
|
||||||
>
|
|
||||||
{getResidentName(day, 1)}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if slotCount == 2}
|
|
||||||
<button
|
|
||||||
onclick={() => "handleCellClick(day, 2)"}
|
|
||||||
class="w-full overflow-hidden rounded border border-emerald-200 bg-emerald-50 px-1.5 py-1 text-left text-[10px] font-bold text-emerald-600 transition-colors hover:bg-emerald-100"
|
|
||||||
>
|
|
||||||
{getResidentName(day, 2)}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -16,6 +16,15 @@ export interface ForbiddenPair {
|
|||||||
id2: number;
|
id2: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ResidentMetrics = {
|
||||||
|
name: string;
|
||||||
|
total: number;
|
||||||
|
open_first: number;
|
||||||
|
open_second: number;
|
||||||
|
closed: number;
|
||||||
|
holiday: number;
|
||||||
|
};
|
||||||
|
|
||||||
export class RotaState {
|
export class RotaState {
|
||||||
currentStep = $state(1);
|
currentStep = $state(1);
|
||||||
residentsCounter = $state(0);
|
residentsCounter = $state(0);
|
||||||
@@ -25,6 +34,11 @@ export class RotaState {
|
|||||||
holidays = $state<CalendarDate[]>([]);
|
holidays = $state<CalendarDate[]>([]);
|
||||||
forbiddenPairs = $state<ForbiddenPair[]>([]);
|
forbiddenPairs = $state<ForbiddenPair[]>([]);
|
||||||
|
|
||||||
|
engineStatus = $state(EngineStatus.Idle);
|
||||||
|
lastMessage = $state("");
|
||||||
|
|
||||||
|
metrics: ResidentMetrics[] = $state([]);
|
||||||
|
|
||||||
projectMonth = $state(new CalendarDate(2026, 2, 1));
|
projectMonth = $state(new CalendarDate(2026, 2, 1));
|
||||||
|
|
||||||
syncProjectMonth() {
|
syncProjectMonth() {
|
||||||
@@ -50,7 +64,9 @@ export class RotaState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
removeResident(id: number) {
|
removeResident(id: number) {
|
||||||
this.residents = this.residents.filter((r) => r.id !== id);
|
const index = this.residents.findIndex((r) => r.id === id);
|
||||||
|
if (index !== -1) this.residents.splice(index, 1);
|
||||||
|
this.forbiddenPairs = this.forbiddenPairs.filter((p) => p.id1 !== id && p.id2 !== id);
|
||||||
}
|
}
|
||||||
|
|
||||||
findResident(id: number) {
|
findResident(id: number) {
|
||||||
@@ -92,6 +108,13 @@ export class RotaState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum EngineStatus {
|
||||||
|
Idle = "ΣΕ ΑΝΑΜΟΝΗ",
|
||||||
|
Running = "Ο ΑΛΓΟΡΙΘΜΟΣ ΤΡΕΧΕΙ...",
|
||||||
|
Success = "ΕΠΙΤΥΧΗΣ ΔΗΜΙΟΥΡΓΙΑ",
|
||||||
|
Error = "ΣΦΑΛΜΑ ΣΥΣΤΗΜΑΤΟΣ"
|
||||||
|
}
|
||||||
|
|
||||||
export const rota = new RotaState();
|
export const rota = new RotaState();
|
||||||
|
|
||||||
export type MonthlyScheduleDTO = {
|
export type MonthlyScheduleDTO = {
|
||||||
@@ -141,11 +164,6 @@ export const steps = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 4,
|
||||||
title: "Επισκόπηση",
|
|
||||||
description: "Έλεγξε το πρόγραμμα με τις υποχρεωτικές υπάρχουσες εφημερίες."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
title: "Δημιουργία",
|
title: "Δημιουργία",
|
||||||
description: "Τρέξε τον αλγόριθμο ανάθεσης εφημεριών, εξήγαγε τα αποτελέσματα."
|
description: "Τρέξε τον αλγόριθμο ανάθεσης εφημεριών, εξήγαγε τα αποτελέσματα."
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user