1use crate::backend::{QtThread, Shared, json, load_config, say, tr};
10use crate::work::{self, loaded};
11use jiff::Zoned;
12use jiff::civil::Date;
13use jiff::tz::TimeZone;
14use serde::Deserialize;
15use sioul_core::budget::{self, Line};
16use sioul_core::projects::{self, ProjectEdit, ProjectStore};
17use sioul_core::invoice::{self, Invoice};
18use sioul_core::money::Money;
19use sioul_core::timelog::{self, Session};
20use sioul_core::timereport::{self, Entry};
21use sioul_core::{links, projectview};
22use std::path::PathBuf;
23use std::sync::Arc;
24
25fn store() -> Result<ProjectStore, String> {
26 let root = load_config().notes_root_path().ok_or_else(|| tr().text("error-no-store", None))?;
27 ProjectStore::load(&root)
28}
29
30fn entries(shared: &Shared) -> Vec<Entry> {
32 let loaded = loaded(shared);
33 timereport::entries(&timelog::sessions(), &loaded.tasks, &loaded.projects, &TimeZone::system())
34}
35
36pub(crate) fn rows(shared: &Shared) -> String {
38 let loaded = loaded(shared);
39 json(&projectview::rows(&loaded, &entries(shared), &loaded.projects))
40}
41
42pub(crate) fn page(shared: &Shared, id: &str) -> String {
44 let loaded = loaded(shared);
45 let Some(project) = loaded.projects.iter().find(|c| c.id == id) else { return String::new() };
46 let config = load_config();
47 let now = Zoned::now().timestamp().as_second();
48 let gate = sioul_core::porch::Gate::load(&config, now);
50 let view = projectview::view(&loaded, project, &entries(shared), &invoice::all_in(&invoice::folder()), config.invoice.rate, now, tr(), &gate);
51 json(&view)
52}
53
54pub(crate) fn save(qt: &QtThread, shared: &Arc<Shared>, id: &str, edit: &str) -> String {
56 let result = serde_json::from_str::<ProjectEdit>(edit).map_err(|e| e.to_string()).and_then(|edit| {
57 if edit.title.trim().is_empty() {
58 return Err(tr().text("project-no-title", None));
59 }
60 let store = store()?;
61 projects::save_project(&store.file(), id, &edit)
62 });
63 work::refresh(qt, shared);
64 match result {
65 Ok(id) => serde_json::json!({ "id": id }).to_string(),
66 Err(error) => serde_json::json!({ "error": error }).to_string(),
67 }
68}
69
70pub(crate) fn remove(qt: &QtThread, shared: &Arc<Shared>, id: &str) -> String {
73 let result = store().and_then(|store| projects::remove_project(&store.file(), id));
74 work::refresh(qt, shared);
75 result.err().unwrap_or_default()
76}
77
78pub(crate) fn save_budget(qt: &QtThread, shared: &Arc<Shared>, id: &str, edit: &str) -> String {
80 let result = serde_json::from_str::<budget::BudgetEdit>(edit).map_err(|e| e.to_string()).and_then(|edit| {
81 let root = load_config().notes_root_path().ok_or_else(|| tr().text("error-no-store", None))?;
82 budget::save_budget(&root.join(budget::LEDGER), id, &edit)
83 });
84 crate::backend::show(qt, shared);
85 match result {
86 Ok(id) => serde_json::json!({ "id": id }).to_string(),
87 Err(error) => serde_json::json!({ "error": error }).to_string(),
88 }
89}
90
91pub(crate) fn remove_budget(qt: &QtThread, shared: &Arc<Shared>, id: &str) -> String {
94 let Some(root) = load_config().notes_root_path() else { return tr().text("error-no-store", None) };
95 let result = budget::remove_budget(&root.join(budget::LEDGER), id);
96 crate::backend::show(qt, shared);
97 result.err().unwrap_or_default()
98}
99
100fn line_said(result: Result<bool, String>) -> String {
104 match result {
105 Ok(true) => String::new(),
106 Ok(false) => tr().text("budget-line-gone", None),
107 Err(e) => e,
108 }
109}
110
111pub(crate) fn remove_line(qt: &QtThread, shared: &Arc<Shared>, uri: &str, shown: &str) -> String {
115 let Some(root) = load_config().notes_root_path() else { return tr().text("error-no-store", None) };
116 let path = root.join(budget::LEDGER);
117 let result = match budget::SeenLine::from_page(uri, shown) {
118 Some((place, seen)) => budget::remove_line(&path, place, &seen),
119 None => Ok(false),
120 };
121 crate::backend::show(qt, shared);
122 line_said(result)
123}
124
125pub(crate) fn change_line(qt: &QtThread, shared: &Arc<Shared>, uri: &str, shown: &str, label: &str, amount: f64, date: &str) -> String {
129 let Some(root) = load_config().notes_root_path() else { return tr().text("error-no-store", None) };
130 let path = root.join(budget::LEDGER);
131 let today = Zoned::now().date();
132 let result = match budget::SeenLine::from_page(uri, shown) {
133 Some((place, seen)) => budget::change_line(&path, place, &seen, label, amount, date.parse().unwrap_or(today), today),
134 None => Ok(false),
135 };
136 crate::backend::show(qt, shared);
137 line_said(result)
138}
139
140pub(crate) fn time(shared: &Shared, period: &str, anchor: &str, project: &str) -> String {
142 let loaded = loaded(shared);
143 let today = Zoned::now().date();
144 let anchor = anchor.parse::<Date>().unwrap_or(today);
145 let sessions = timelog::sessions();
146 let by_hand: std::collections::BTreeSet<String> = sessions.iter().filter(|s| s.task.is_empty() || !s.project.is_empty()).map(Session::key).collect();
148 let all = timereport::entries(&sessions, &loaded.tasks, &loaded.projects, &TimeZone::system());
149 let config = load_config();
150 let mut view = timereport::view(&all, &loaded.projects, &|key| by_hand.contains(key), period, anchor, Some(project).filter(|p| !p.is_empty()), config.invoice.rate, today, tr());
151 let kinds: std::collections::BTreeMap<String, Option<timelog::Kind>> = sessions.iter().map(|s| (s.key(), s.kind)).collect();
153 for entry in &mut view.entries {
154 let kind = kinds.get(&entry.key).copied().flatten();
155 entry.kind = timelog::Kind::id(kind).to_string();
156 entry.kind_said = tr().text(&format!("time-kind-{}", entry.kind), None);
157 }
158 json(&view)
159}
160
161pub(crate) fn export_time_csv(shared: &Shared, project: &str, from: &str, to: &str, target: &str) -> Result<String, String> {
164 let loaded = loaded(shared);
165 let Some(found) = loaded.projects.iter().find(|c| c.id == project) else { return Err(tr().text("project-gone", None)) };
166 let (Ok(from), Ok(to)) = (from.parse::<Date>(), to.parse::<Date>()) else { return Err(tr().text("time-bad-day", None)) };
167 let all = timereport::entries(&timelog::sessions(), &loaded.tasks, &loaded.projects, &TimeZone::system());
168 let config = load_config();
169 let french = tr().text("qt-locale", None).starts_with("fr");
170 let words = ["time-csv-what", "time-csv-hours", "time-csv-rate", "time-csv-amount", "time-csv-total"].map(|w| tr().text(w, None));
171 let csv = timereport::billable_csv(&all, found, from, to, config.invoice.rate, french, [&words[0], &words[1], &words[2], &words[3], &words[4]]);
172 let path = crate::backend::local_path(target);
173 std::fs::write(&path, csv).map_err(|e| format!("{}: {e}", path.display()))?;
174 Ok(path.display().to_string())
175}
176
177pub(crate) fn percent_decoded(path: &str) -> String {
179 let bytes = path.as_bytes();
180 let mut out = Vec::with_capacity(bytes.len());
181 let mut i = 0;
182 while i < bytes.len() {
183 if bytes[i] == b'%'
184 && let Some(byte) = path.get(i + 1..i + 3).and_then(|h| u8::from_str_radix(h, 16).ok())
185 {
186 out.push(byte);
187 i += 3;
188 continue;
189 }
190 out.push(bytes[i]);
191 i += 1;
192 }
193 String::from_utf8_lossy(&out).to_string()
194}
195
196#[derive(Deserialize)]
198struct TimeEdit {
199 day: String,
201 #[serde(default)]
203 at: String,
204 minutes: u32,
205 #[serde(default)]
206 project: String,
207 #[serde(default)]
208 task: String,
209 #[serde(default)]
210 note: String,
211 #[serde(default)]
212 unbilled: bool,
213}
214
215pub(crate) fn note_time(qt: &QtThread, shared: &Arc<Shared>, edit: &str) -> String {
217 let result = serde_json::from_str::<TimeEdit>(edit).map_err(|e| e.to_string()).and_then(|edit| {
218 if edit.minutes == 0 {
219 return Err(tr().text("time-no-minutes", None));
220 }
221 if edit.project.is_empty() && edit.task.is_empty() {
222 return Err(tr().text("time-no-project-chosen", None));
223 }
224 let day = edit.day.parse::<Date>().map_err(|_| tr().text("time-bad-day", None))?;
225 let (hour, minute) = edit.at.split_once(':').and_then(|(h, m)| Some((h.trim().parse::<i8>().ok()?, m.trim().parse::<i8>().ok()?))).unwrap_or((12, 0));
226 let start = day.at(hour.clamp(0, 23), minute.clamp(0, 59), 0, 0).to_zoned(TimeZone::system()).map_err(|e| e.to_string())?.timestamp().as_second();
227 timelog::record(&Session { task: edit.task, project: edit.project, start, minutes: edit.minutes, note: edit.note.trim().to_string(), unbilled: edit.unbilled, kind: Some(timelog::Kind::Typed), ..Session::default() })
228 });
229 work::refresh(qt, shared);
230 result.err().unwrap_or_default()
231}
232
233#[derive(Deserialize)]
236struct TimeChange {
237 day: String,
238 from: String,
239 to: String,
240 #[serde(default)]
241 project: String,
242 #[serde(default)]
243 task: String,
244 #[serde(default)]
245 note: String,
246 #[serde(default)]
247 unbilled: bool,
248}
249
250pub(crate) fn change_time(qt: &QtThread, shared: &Arc<Shared>, key: &str, edit: &str) -> String {
254 let result = serde_json::from_str::<TimeChange>(edit).map_err(|e| e.to_string()).and_then(|edit| {
255 if timelog::sessions().iter().any(|s| s.key() == key && !s.invoice.is_empty()) {
256 return Err(tr().text("time-billed-stays", None));
257 }
258 let day = edit.day.parse::<Date>().map_err(|_| tr().text("time-bad-day", None))?;
259 let clock = |text: &str| text.split_once(':').and_then(|(h, m)| jiff::civil::Time::new(h.trim().parse().ok()?, m.trim().parse().ok()?, 0, 0).ok());
260 let (Some(from), Some(to)) = (clock(&edit.from), clock(&edit.to)) else { return Err(tr().text("time-bad-hours", None)) };
261 let start = day.to_datetime(from).to_zoned(TimeZone::system()).map_err(|e| e.to_string())?.timestamp().as_second();
262 let mut end = day.to_datetime(to).to_zoned(TimeZone::system()).map_err(|e| e.to_string())?.timestamp().as_second();
264 if end <= start {
265 end += 86_400;
266 }
267 let minutes = u32::try_from((end - start + 30) / 60).unwrap_or(0);
268 if minutes == 0 {
269 return Err(tr().text("time-no-minutes", None));
270 }
271 if edit.project.is_empty() && edit.task.is_empty() {
272 return Err(tr().text("time-no-project-chosen", None));
273 }
274 let changed = timelog::change_in(&timelog::folder(), key, |s| {
275 s.start = start;
276 s.set_minutes(minutes);
278 s.task = edit.task.clone();
279 s.project = edit.project.clone();
280 s.note = edit.note.trim().to_string();
281 s.unbilled = edit.unbilled;
282 })?;
283 if changed { Ok(()) } else { Err(tr().text("time-gone", None)) }
284 });
285 work::refresh(qt, shared);
286 result.err().unwrap_or_default()
287}
288
289pub(crate) fn task_choices(shared: &Shared, keep: &str) -> String {
292 let loaded = loaded(shared);
293 let mut tasks: Vec<&sioul_core::tasks::Task> = loaded.tasks.iter().filter(|t| t.status.is_open() || t.uid == keep).collect();
294 tasks.sort_by_key(|t| t.title.to_lowercase());
295 json(&tasks.iter().map(|t| serde_json::json!({ "uid": t.uid, "title": t.title, "project": t.projects.first().cloned().unwrap_or_default() })).collect::<Vec<_>>())
296}
297
298pub(crate) fn remove_time(qt: &QtThread, shared: &Arc<Shared>, key: &str) -> String {
300 let billed = timelog::sessions().iter().any(|s| s.key() == key && !s.invoice.is_empty());
301 if billed {
302 return tr().text("time-billed-stays", None);
303 }
304 let result = timelog::remove_in(&timelog::folder(), &[key.to_string()]);
305 work::refresh(qt, shared);
306 match result {
307 Ok(_) => String::new(),
308 Err(e) => e,
309 }
310}
311
312fn pdf_path(invoice: &Invoice) -> PathBuf {
314 let config = load_config();
315 let folder = if config.invoice.folder.trim().is_empty() {
316 sioul_core::config::expand_home(&format!("~/Documents/{}", tr().text("invoice-folder-name", None)))
317 } else {
318 sioul_core::config::expand_home(config.invoice.folder.trim())
319 };
320 let client: String = invoice.client.chars().filter(|c| c.is_alphanumeric() || *c == ' ' || *c == '-').collect();
321 let number: String = invoice.number.chars().map(|c| if c.is_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.') { c } else { '-' }).collect();
323 folder.join(format!("{} {}.pdf", number.trim_matches('.'), client.trim()).trim().to_string())
324}
325
326fn invoices_held_elsewhere(shared: &Shared) -> Option<String> {
329 let active = shared.active.load(std::sync::atomic::Ordering::Relaxed);
330 let (keeper, unwritten) = crate::share::keeper(INVOICES, sioul_sync::lease::Rule::StaysPut, active, false);
331 let now = jiff::Timestamp::now().as_second();
332 let refused = |text: String, elsewhere: bool| Some(serde_json::json!({ "error": text, "elsewhere": elsewhere }).to_string());
333 if unwritten {
334 return refused(tr().text("invoice-unchecked", None), false);
335 }
336 if !keeper.mine {
337 return refused(say("invoice-elsewhere", &[("computer", keeper.name)]), true);
338 }
339 if !keeper.settled {
340 return refused(tr().text("invoice-settling", None), false);
341 }
342 if keeper.others > 0 && now - keeper.others_heard > 3 * 60 {
344 return refused(tr().text("invoice-others-silent", None), false);
345 }
346 None
347}
348
349pub(crate) const INVOICES: &str = "invoices";
351
352pub(crate) fn take_invoices(shared: &Shared) -> String {
354 let active = shared.active.load(std::sync::atomic::Ordering::Relaxed);
355 let (keeper, unwritten) = crate::share::keeper(INVOICES, sioul_sync::lease::Rule::StaysPut, active, true);
356 if unwritten {
357 tr().text("invoice-unchecked", None)
358 } else if keeper.settled {
359 tr().text("invoice-here", None)
360 } else {
361 tr().text("invoice-settling", None)
362 }
363}
364
365pub(crate) fn keep_invoices(shared: &Shared) {
367 if !load_config().invoice.name.trim().is_empty() && crate::share::on() {
368 let active = shared.active.load(std::sync::atomic::Ordering::Relaxed);
369 let _ = crate::share::keeper(INVOICES, sioul_sync::lease::Rule::StaysPut, active, false);
370 }
371}
372
373fn client_of(shared: &Shared, client: &str) -> (String, String) {
375 let loaded = loaded(shared);
376 let named = || loaded.contacts.iter().find(|c| !client.trim().is_empty() && (c.name.eq_ignore_ascii_case(client.trim()) || c.org.eq_ignore_ascii_case(client.trim()))).cloned();
378 match client.strip_prefix("sioul:contact/").map(sioul_core::notes::decode).and_then(|uid| loaded.contacts.iter().find(|c| c.uid == uid).cloned()).or_else(named) {
379 Some(contact) => {
380 let name = if contact.org.is_empty() || contact.org == contact.name { contact.name.clone() } else { format!("{}\n{}", contact.org, contact.name) };
381 (name, contact.addresses.first().map(|a| a.value.clone()).unwrap_or_default())
382 }
383 None => (client.to_string(), String::new()),
384 }
385}
386
387pub(crate) fn make_invoice(qt: &QtThread, shared: &Arc<Shared>, id: &str) -> String {
391 if let Some(elsewhere) = invoices_held_elsewhere(shared) {
393 return elsewhere;
394 }
395 let result = (|| -> Result<serde_json::Value, String> {
396 let config = load_config();
397 let store = store()?;
398 let project = store.get(id).ok_or_else(|| tr().text("project-gone", None))?.clone();
399 let existing = invoice::all_in(&invoice::folder());
400 let today = Zoned::now().date();
401 let number = invoice::next_number(&existing, &config.invoice.prefix, today.year());
402 let (client, address) = client_of(shared, project.client.as_deref().unwrap_or(""));
403 let made = invoice::make(&entries(shared), &project, &client, &address, &config.invoice, &number, today).ok_or_else(|| tr().text("invoice-nothing", None))?;
404 invoice::save_in(&invoice::folder(), &made)?;
405 timelog::update_in(&timelog::folder(), &made.sessions, |s| s.invoice = number.clone())?;
406 if let Some(budget_id) = project.budget.as_deref().filter(|b| !b.is_empty()) {
408 let ledger = store.root.join(budget::LEDGER);
409 let line = Line {
410 budget: budget_id.to_string(),
411 date: made.due.parse().unwrap_or(today),
412 amount: Money(made.total_cents),
413 label: say("invoice-budget-line", &[("number", number.clone()), ("client", client.lines().next().unwrap_or("").to_string())]),
414 planned: true,
415 reserve: None,
416 links: vec![format!("sioul:invoice/{number}"), links::project_uri(&project.id)],
417 preset: None,
418 };
419 budget::record_line(&ledger, &line, &say("invoice-budget-origin", &[("number", number.clone())]))?;
420 }
421 Ok(serde_json::json!({ "number": number, "html": invoice::html(&made, tr()), "pdf": pdf_path(&made).display().to_string() }))
422 })();
423 work::refresh(qt, shared);
424 match result {
425 Ok(answer) => answer.to_string(),
426 Err(error) => serde_json::json!({ "error": error }).to_string(),
427 }
428}
429
430pub(crate) fn invoice_again(number: &str) -> String {
432 match invoice::all_in(&invoice::folder()).into_iter().find(|i| i.number == number) {
433 Some(found) => serde_json::json!({ "html": invoice::html(&found, tr()), "pdf": pdf_path(&found).display().to_string() }).to_string(),
434 None => serde_json::json!({ "error": tr().text("invoice-gone", None) }).to_string(),
435 }
436}
437
438pub(crate) fn set_paid(qt: &QtThread, shared: &Arc<Shared>, number: &str, paid: bool) -> String {
441 let result = (|| -> Result<(), String> {
442 let mut found = invoice::all_in(&invoice::folder()).into_iter().find(|i| i.number == number).ok_or_else(|| tr().text("invoice-gone", None))?;
443 found.paid = paid;
444 invoice::save_in(&invoice::folder(), &found)?;
445 if paid && let Ok(store) = store() {
446 budget::settle_line(&store.root.join(budget::LEDGER), &format!("sioul:invoice/{number}"), Zoned::now().date())?;
447 }
448 Ok(())
449 })();
450 work::refresh(qt, shared);
451 result.err().unwrap_or_default()
452}