diff --git a/README.md b/README.md index 92eae44..ce830e1 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Companies that use Atlassian Jira for project management and ERPNext for time tr ## Features -- Allows logging of miscellanous time, project time and breaks +- Allows logging of miscellaneous time, project time, breaks and paid breaks - Allows to set a percentage of working time as billable time in a Working Time Log - Rounds billable time to 5 minutes - Fetches issue titles from Jira (used as time log description) @@ -17,8 +17,8 @@ Companies that use Atlassian Jira for project management and ERPNext for time tr - If a draft working time entry is older than 3 days, and - on the last working day of the month - **Working Time Policy** enforcement per employee, including: - - Maximum working time per day - - Mandatory break requirements based on working time thresholds + - Maximum productive time per day + - Mandatory break requirements based on productive time thresholds - Minimum rest time between days - Blocked weekdays - Holiday blocking (based on the employee's holiday list) @@ -45,6 +45,22 @@ Companies that use Atlassian Jira for project management and ERPNext for time tr - Add a time log and link it to a _Project_ and Jira issue _Key_ - Submit your **Working Time** +## Time Fields + +**Working Time** separates productive time, break time and paid time: + +- _Productive Time_ (`productive_time`) is the total duration of logs that are not marked as breaks. +- _Break Time_ (`break_time`) is the total duration of logs marked as breaks, including paid breaks. +- _Paid Break Time_ (`paid_break_time`) is the total duration of break logs where _Paid_ (`is_paid_break`) is enabled. +- _Paid Working Time_ (`working_time`) is the paid total: _Productive Time_ plus _Paid Break Time_. Number cards, stats and reports use this field as the paid working time total. +- _Project Time_ (`project_time`) and _Billable Time_ (`billable_time`) are calculated from non-break project logs. + +In a **Working Time Log**, mark _Break_ (`is_break`) for any physical break. Regular working logs are paid by default and should not be marked as breaks. Regular breaks are unpaid by default. Use _Paid_ (`is_paid_break`) only for exceptional break rows that should count toward paid working time, such as mandatory but passive travel time. + +**Working Time Policy** restrictions use _Productive Time_ for maximum time and threshold checks, while mandatory break requirements use _Break Time_. This means paid breaks count as paid time, but do not increase productive time for policy restrictions. + +German users may refer to [this article](https://www.kanzlei-chevalier.de/blog/dienstreise-als-arbeitszeit) for more information. + ## Further Reading Want to add pretty time logs to your invoice? Check out our [print formats](https://github.com/alyf-de/erpnext_druckformate). diff --git a/working_time/locale/de.po b/working_time/locale/de.po index fac6e8f..5d22390 100644 --- a/working_time/locale/de.po +++ b/working_time/locale/de.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Working Time VERSION\n" "Report-Msgid-Bugs-To: hallo@alyf.de\n" -"POT-Creation-Date: 2026-03-27 14:57+0053\n" +"POT-Creation-Date: 2026-05-26 23:08+0053\n" "PO-Revision-Date: 2026-03-27 14:57+0053\n" "Last-Translator: hallo@alyf.de\n" "Language-Team: hallo@alyf.de\n" @@ -112,13 +112,16 @@ msgstr "Gesperrte Tage" msgid "Blocks real holidays from an employee's holiday list. Ignores weekly off." msgstr "Sperrt echte Feiertage aus der Feiertagsliste des Mitarbeiters. Wöchentliche freie Tage werden ignoriert." -#. Label of a Duration field in DocType 'Working Time' #. Label of a Check field in DocType 'Working Time Log' -#: working_time/working_time/doctype/working_time/working_time.json #: working_time/working_time/doctype/working_time_log/working_time_log.json msgid "Break" msgstr "Pause" +#. Label of a Duration field in DocType 'Working Time' +#: working_time/working_time/doctype/working_time/working_time.json +msgid "Break Time" +msgstr "Pausenzeit" + #. Label of a number card in the Time Tracking Workspace #: working_time/working_time/workspace/time_tracking/time_tracking.json msgid "Daily Billable Time (this month)" @@ -146,11 +149,11 @@ msgstr "Tägliche Arbeitsstunden" msgid "Daily Working Time" msgstr "Tägliche Arbeitszeit" -#: working_time/reminders.py:138 +#: working_time/reminders.py:146 msgid "" "Dear {first_name},\n" "\n" -"Your have a draft working time entry that is older than {cutoff_days} days. Please submit it as soon as possible.\n" +"You have a draft working time entry that is older than {cutoff_days} days. Please submit it as soon as possible.\n" "\n" "Thanks in advance!" msgstr "" @@ -160,7 +163,7 @@ msgstr "" "\n" "Vielen Dank!" -#: working_time/reminders.py:63 +#: working_time/reminders.py:66 msgid "" "Dear {first_name},\n" "\n" @@ -174,6 +177,11 @@ msgstr "" "\n" "Vielen Dank!" +#. Description of the 'Paid' (Check) field in DocType 'Working Time Log' +#: working_time/working_time/doctype/working_time_log/working_time_log.json +msgid "Enable to count this break as paid working time (e.g. mandatory travel time)" +msgstr "Aktivieren, um diese Pause als bezahlte Arbeitszeit zu zählen (z.B. Pflichtreisezeit)" + #: working_time/working_time/report/expected_and_actual_working_time/expected_and_actual_working_time.py:58 msgid "Expected Working Time" msgstr "Erwartete Arbeitszeit" @@ -242,8 +250,8 @@ msgstr "Pflichtpausen" #. Label of a Duration field in DocType 'Working Time Policy' #: working_time/working_time/doctype/working_time_policy/working_time_policy.json -msgid "Max Working Time Per Day" -msgstr "Maximale Arbeitszeit pro Tag" +msgid "Max Productive Time Per Day" +msgstr "Maximale produktive Arbeitszeit pro Tag" #. Label of a Duration field in DocType 'Working Time Policy' #: working_time/working_time/doctype/working_time_policy/working_time_policy.json @@ -268,14 +276,37 @@ msgstr "Nicht Plausibel" msgid "Only submitted working times are considered in this table." msgstr "Nur gebuchte Arbeitszeiten werden in dieser Tabelle berücksichtigt." -#: working_time/working_time/doctype/working_time/working_time.py:55 +#. Label of a Duration field in DocType 'Working Time' +#: working_time/working_time/doctype/working_time/working_time.json +msgid "Paid Break Time" +msgstr "Bezahlte Pausenzeit" + +#. Label of a Duration field in DocType 'Working Time' +#: working_time/working_time/doctype/working_time/working_time.json +msgid "Paid Working Time" +msgstr "Bezahlte Arbeitszeit" + +#: working_time/working_time/doctype/working_time/working_time.py:64 msgid "Please add an issue key or invoice note to the billable row {0}" msgstr "Bitte einen Issue-Key oder eine Rechnungsnotiz zur abrechenbaren Zeile {0} hinzufügen" -#: working_time/working_time/doctype/working_time/working_time.py:46 +#: working_time/working_time/doctype/working_time/working_time.py:55 msgid "Please fix negative duration in row {0}" msgstr "Bitte negative Dauer in Zeile {0} korrigieren" +#. Label of a Duration field in DocType 'Working Time' +#: working_time/working_time/doctype/working_time/working_time.json +msgid "Productive Time" +msgstr "Produktive Arbeitszeit" + +#: working_time/working_time/doctype/working_time/working_time.py:116 +msgid "Productive time ({0}) exceeds the maximum allowed ({1}) per day" +msgstr "Produktive Arbeitszeit ({0}) überschreitet die maximal erlaubte ({1}) pro Tag" + +#: working_time/working_time/doctype/working_time/working_time.py:129 +msgid "Productive time of {0} or more requires at least {1} of break time" +msgstr "Produktive Arbeitszeit von {0} oder mehr benötigt mindestens {1} Pausenzeit" + #. Label of a Percent field in DocType 'Working Time' #: working_time/working_time/doctype/working_time/working_time.json msgid "Project %" @@ -286,7 +317,7 @@ msgstr "Projekt %" msgid "Project Time" msgstr "Projektzeit" -#: working_time/reminders.py:62 working_time/reminders.py:137 +#: working_time/reminders.py:65 working_time/reminders.py:145 msgid "Remember to submit your working time" msgstr "Erinnerung: Arbeitszeit einreichen" @@ -295,7 +326,7 @@ msgstr "Erinnerung: Arbeitszeit einreichen" msgid "Required Break Minutes" msgstr "Erforderliche Pausenminuten" -#: working_time/working_time/doctype/working_time/working_time.py:164 +#: working_time/working_time/doctype/working_time/working_time.py:173 msgid "Rest time since previous day ({0}) is less than the required minimum ({1})" msgstr "Die Ruhezeit seit dem Vortag ({0}) ist kürzer als das erforderliche Minimum ({1})" @@ -332,7 +363,6 @@ msgid "Work Threshold" msgstr "Arbeitsschwelle" #. Name of a DocType -#. Label of a Duration field in DocType 'Working Time' #. Label of a Link in the Time Tracking Workspace #: working_time/config/desktop.py:11 #: working_time/working_time/doctype/working_time/working_time.json @@ -370,24 +400,16 @@ msgstr "Arbeitszeitrichtlinie" msgid "Working Time Summary" msgstr "Arbeitszeit Zusammenfassung" -#: working_time/working_time/doctype/working_time/working_time.py:107 -msgid "Working time ({0}) exceeds the maximum allowed ({1}) per day" -msgstr "Die Arbeitszeit ({0}) überschreitet das erlaubte Maximum ({1}) pro Tag" - -#: working_time/working_time/doctype/working_time/working_time.py:120 -msgid "Working time of {0} or more requires at least {1} of break time" -msgstr "Eine Arbeitszeit von {0} oder mehr erfordert mindestens {1} Pausenzeit" - #. Description of the 'Site URL' (Data) field in DocType 'Jira Site' #: working_time/working_time/doctype/jira_site/jira_site.json msgid "e.g. your-domain.atlassian.net" msgstr "z.B. your-domain.atlassian.net" -#: working_time/working_time/doctype/working_time/working_time.py:80 +#: working_time/working_time/doctype/working_time/working_time.py:89 msgid "{0} is a blocked day according to the Working Time Policy" msgstr "{0} ist laut Arbeitszeitrichtlinie ein gesperrter Tag" -#: working_time/working_time/doctype/working_time/working_time.py:96 +#: working_time/working_time/doctype/working_time/working_time.py:105 msgid "{0} is a holiday according to your holiday list" msgstr "{0} ist laut Ihrer Feiertagsliste ein Feiertag" @@ -395,3 +417,4 @@ msgstr "{0} ist laut Ihrer Feiertagsliste ein Feiertag" #: working_time/working_time/workspace/time_tracking/time_tracking.json msgid "{} Drafts" msgstr "{} Entwürfe" + diff --git a/working_time/locale/main.pot b/working_time/locale/main.pot index d18b758..e5934c2 100644 --- a/working_time/locale/main.pot +++ b/working_time/locale/main.pot @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: Working Time VERSION\n" "Report-Msgid-Bugs-To: hallo@alyf.de\n" -"POT-Creation-Date: 2026-03-27 14:57+0053\n" -"PO-Revision-Date: 2026-03-27 14:57+0053\n" +"POT-Creation-Date: 2026-05-26 23:08+0053\n" +"PO-Revision-Date: 2026-05-26 23:08+0053\n" "Last-Translator: hallo@alyf.de\n" "Language-Team: hallo@alyf.de\n" "MIME-Version: 1.0\n" @@ -112,13 +112,16 @@ msgstr "" msgid "Blocks real holidays from an employee's holiday list. Ignores weekly off." msgstr "" -#. Label of a Duration field in DocType 'Working Time' #. Label of a Check field in DocType 'Working Time Log' -#: working_time/working_time/doctype/working_time/working_time.json #: working_time/working_time/doctype/working_time_log/working_time_log.json msgid "Break" msgstr "" +#. Label of a Duration field in DocType 'Working Time' +#: working_time/working_time/doctype/working_time/working_time.json +msgid "Break Time" +msgstr "" + #. Label of a number card in the Time Tracking Workspace #: working_time/working_time/workspace/time_tracking/time_tracking.json msgid "Daily Billable Time (this month)" @@ -146,16 +149,16 @@ msgstr "" msgid "Daily Working Time" msgstr "" -#: working_time/reminders.py:138 +#: working_time/reminders.py:146 msgid "" "Dear {first_name},\n" "\n" -"Your have a draft working time entry that is older than {cutoff_days} days. Please submit it as soon as possible.\n" +"You have a draft working time entry that is older than {cutoff_days} days. Please submit it as soon as possible.\n" "\n" "Thanks in advance!" msgstr "" -#: working_time/reminders.py:63 +#: working_time/reminders.py:66 msgid "" "Dear {first_name},\n" "\n" @@ -164,6 +167,11 @@ msgid "" "Thanks in advance!" msgstr "" +#. Description of the 'Paid' (Check) field in DocType 'Working Time Log' +#: working_time/working_time/doctype/working_time_log/working_time_log.json +msgid "Enable to count this break as paid working time (e.g. mandatory travel time)" +msgstr "" + #: working_time/working_time/report/expected_and_actual_working_time/expected_and_actual_working_time.py:58 msgid "Expected Working Time" msgstr "" @@ -232,7 +240,7 @@ msgstr "" #. Label of a Duration field in DocType 'Working Time Policy' #: working_time/working_time/doctype/working_time_policy/working_time_policy.json -msgid "Max Working Time Per Day" +msgid "Max Productive Time Per Day" msgstr "" #. Label of a Duration field in DocType 'Working Time Policy' @@ -258,14 +266,37 @@ msgstr "" msgid "Only submitted working times are considered in this table." msgstr "" -#: working_time/working_time/doctype/working_time/working_time.py:55 +#. Label of a Duration field in DocType 'Working Time' +#: working_time/working_time/doctype/working_time/working_time.json +msgid "Paid Break Time" +msgstr "" + +#. Label of a Duration field in DocType 'Working Time' +#: working_time/working_time/doctype/working_time/working_time.json +msgid "Paid Working Time" +msgstr "" + +#: working_time/working_time/doctype/working_time/working_time.py:64 msgid "Please add an issue key or invoice note to the billable row {0}" msgstr "" -#: working_time/working_time/doctype/working_time/working_time.py:46 +#: working_time/working_time/doctype/working_time/working_time.py:55 msgid "Please fix negative duration in row {0}" msgstr "" +#. Label of a Duration field in DocType 'Working Time' +#: working_time/working_time/doctype/working_time/working_time.json +msgid "Productive Time" +msgstr "" + +#: working_time/working_time/doctype/working_time/working_time.py:116 +msgid "Productive time ({0}) exceeds the maximum allowed ({1}) per day" +msgstr "" + +#: working_time/working_time/doctype/working_time/working_time.py:129 +msgid "Productive time of {0} or more requires at least {1} of break time" +msgstr "" + #. Label of a Percent field in DocType 'Working Time' #: working_time/working_time/doctype/working_time/working_time.json msgid "Project %" @@ -276,7 +307,7 @@ msgstr "" msgid "Project Time" msgstr "" -#: working_time/reminders.py:62 working_time/reminders.py:137 +#: working_time/reminders.py:65 working_time/reminders.py:145 msgid "Remember to submit your working time" msgstr "" @@ -285,7 +316,7 @@ msgstr "" msgid "Required Break Minutes" msgstr "" -#: working_time/working_time/doctype/working_time/working_time.py:164 +#: working_time/working_time/doctype/working_time/working_time.py:173 msgid "Rest time since previous day ({0}) is less than the required minimum ({1})" msgstr "" @@ -322,7 +353,6 @@ msgid "Work Threshold" msgstr "" #. Name of a DocType -#. Label of a Duration field in DocType 'Working Time' #. Label of a Link in the Time Tracking Workspace #: working_time/config/desktop.py:11 #: working_time/working_time/doctype/working_time/working_time.json @@ -360,24 +390,16 @@ msgstr "" msgid "Working Time Summary" msgstr "" -#: working_time/working_time/doctype/working_time/working_time.py:107 -msgid "Working time ({0}) exceeds the maximum allowed ({1}) per day" -msgstr "" - -#: working_time/working_time/doctype/working_time/working_time.py:120 -msgid "Working time of {0} or more requires at least {1} of break time" -msgstr "" - #. Description of the 'Site URL' (Data) field in DocType 'Jira Site' #: working_time/working_time/doctype/jira_site/jira_site.json msgid "e.g. your-domain.atlassian.net" msgstr "" -#: working_time/working_time/doctype/working_time/working_time.py:80 +#: working_time/working_time/doctype/working_time/working_time.py:89 msgid "{0} is a blocked day according to the Working Time Policy" msgstr "" -#: working_time/working_time/doctype/working_time/working_time.py:96 +#: working_time/working_time/doctype/working_time/working_time.py:105 msgid "{0} is a holiday according to your holiday list" msgstr "" diff --git a/working_time/patches.txt b/working_time/patches.txt index 5bf4ed9..495241f 100644 --- a/working_time/patches.txt +++ b/working_time/patches.txt @@ -6,3 +6,5 @@ working_time.patches.link_timesheet_and_attendance_to_working_time # 2023-08-14 working_time.patches.billable_time_pct # 1 working_time.patches.add_total_to_old_freelancer_time execute:from working_time.install import make_custom_fields; make_custom_fields() # 2026-03-27 +working_time.patches.rename_max_working_time_to_productive_time +working_time.patches.backfill_productive_and_paid_break_time # 1 diff --git a/working_time/patches/backfill_productive_and_paid_break_time.py b/working_time/patches/backfill_productive_and_paid_break_time.py new file mode 100644 index 0000000..ef03d3f --- /dev/null +++ b/working_time/patches/backfill_productive_and_paid_break_time.py @@ -0,0 +1,6 @@ +import frappe + + +def execute(): + wt = frappe.qb.DocType("Working Time") + frappe.qb.update(wt).set(wt.productive_time, wt.working_time).set(wt.paid_break_time, 0).run() diff --git a/working_time/patches/rename_max_working_time_to_productive_time.py b/working_time/patches/rename_max_working_time_to_productive_time.py new file mode 100644 index 0000000..26e56b9 --- /dev/null +++ b/working_time/patches/rename_max_working_time_to_productive_time.py @@ -0,0 +1,9 @@ +from frappe.model.utils.rename_field import rename_field + + +def execute(): + rename_field( + "Working Time Policy", + "max_working_time_per_day", + "max_productive_time_per_day", + ) diff --git a/working_time/working_time/doctype/working_time/test_working_time.py b/working_time/working_time/doctype/working_time/test_working_time.py index c7e6b58..5b1be10 100644 --- a/working_time/working_time/doctype/working_time/test_working_time.py +++ b/working_time/working_time/doctype/working_time/test_working_time.py @@ -3,12 +3,23 @@ import unittest +import frappe from frappe import _dict from working_time.working_time.doctype.working_time.working_time import aggregate_time_logs class TestWorkingTime(unittest.TestCase): + def get_working_time(self, time_logs): + return frappe.get_doc( + { + "doctype": "Working Time", + "employee": "Test Employee", + "date": "2026-05-26", + "time_logs": time_logs, + } + ) + def test_aggregate_time_logs(self): logs = [ _dict( @@ -72,3 +83,107 @@ def test_aggregate_time_logs(self): self.assertEqual(project_b["hours"], 2.0) self.assertEqual(project_b["internal_notes"], []) self.assertEqual(project_b["customer_notes"], ["Customer Note 1"]) + + def test_paid_break_totals(self): + working_time = self.get_working_time( + [ + {"from_time": "09:00:00", "to_time": "12:00:00", "is_break": 0}, + { + "from_time": "12:00:00", + "to_time": "12:30:00", + "is_break": 1, + "is_paid_break": 1, + }, + {"from_time": "12:30:00", "to_time": "13:00:00", "is_break": 1}, + {"from_time": "13:00:00", "to_time": "17:00:00", "is_break": 0}, + ] + ) + + working_time.before_validate() + + self.assertEqual(working_time.productive_time, 7 * 60 * 60) + self.assertEqual(working_time.paid_break_time, 30 * 60) + self.assertEqual(working_time.break_time, 60 * 60) + self.assertEqual(working_time.working_time, 7.5 * 60 * 60) + + def test_non_break_clears_paid_break_flag(self): + working_time = self.get_working_time( + [ + { + "from_time": "09:00:00", + "to_time": "10:00:00", + "is_break": 0, + "is_paid_break": 1, + } + ] + ) + + working_time.before_validate() + + self.assertEqual(working_time.time_logs[0].is_paid_break, 0) + self.assertEqual(working_time.productive_time, 60 * 60) + self.assertEqual(working_time.paid_break_time, 0) + + def test_max_working_time_policy_uses_productive_time(self): + policy = _dict({"max_productive_time_per_day": 8 * 60 * 60}) + working_time = self.get_working_time( + [ + {"from_time": "09:00:00", "to_time": "17:00:00", "is_break": 0}, + { + "from_time": "17:00:00", + "to_time": "18:00:00", + "is_break": 1, + "is_paid_break": 1, + }, + ] + ) + working_time.before_validate() + + self.assertEqual(working_time.productive_time, 8 * 60 * 60) + self.assertEqual(working_time.working_time, 9 * 60 * 60) + working_time.validate_max_working_time(policy) + + over_limit = self.get_working_time( + [ + {"from_time": "09:00:00", "to_time": "17:15:00", "is_break": 0}, + ] + ) + over_limit.before_validate() + + self.assertRaises(frappe.ValidationError, over_limit.validate_max_working_time, policy) + + def test_mandatory_breaks_policy_uses_productive_time(self): + policy = _dict( + {"mandatory_breaks": [_dict({"work_threshold": 6 * 60 * 60, "required_break_minutes": 30 * 60})]} + ) + working_time = self.get_working_time( + [ + {"from_time": "09:00:00", "to_time": "14:45:00", "is_break": 0}, + { + "from_time": "14:45:00", + "to_time": "15:05:00", + "is_break": 1, + "is_paid_break": 1, + }, + ] + ) + working_time.before_validate() + + self.assertEqual(working_time.productive_time, 5.75 * 60 * 60) + self.assertEqual(working_time.working_time, (5.75 * 60 * 60) + (20 * 60)) + working_time.validate_mandatory_breaks(policy) + + missing_break = self.get_working_time( + [ + {"from_time": "09:00:00", "to_time": "15:00:00", "is_break": 0}, + { + "from_time": "15:00:00", + "to_time": "15:20:00", + "is_break": 1, + "is_paid_break": 1, + }, + ] + ) + missing_break.before_validate() + + self.assertRaises(frappe.ValidationError, missing_break.validate_mandatory_breaks, policy) diff --git a/working_time/working_time/doctype/working_time/working_time.js b/working_time/working_time/doctype/working_time/working_time.js index 622f3d6..8b623c6 100644 --- a/working_time/working_time/doctype/working_time/working_time.js +++ b/working_time/working_time/doctype/working_time/working_time.js @@ -81,6 +81,12 @@ frappe.ui.form.on("Working Time Log", { ); frappe.model.set_value(cdt, cdn, "to_time", ""); // Otherwise Frappe may overwrite empty values with the current time on save. }, + is_break: function (frm, cdt, cdn) { + const child = locals[cdt][cdn]; + if (!child.is_break) { + frappe.model.set_value(cdt, cdn, "is_paid_break", 0); + } + }, project: function (frm, cdt, cdn) { // set billable time to 0% if Project is of Type "Internal", reset to 100% otherwise const child = locals[cdt][cdn]; diff --git a/working_time/working_time/doctype/working_time/working_time.json b/working_time/working_time/doctype/working_time/working_time.json index 91cf677..5526be9 100644 --- a/working_time/working_time/doctype/working_time/working_time.json +++ b/working_time/working_time/doctype/working_time/working_time.json @@ -15,10 +15,14 @@ "section_break_7", "project_time", "billable_time", - "working_time", "column_break_covil", "project_pct", "billable_pct", + "section_break_cimk", + "productive_time", + "paid_break_time", + "working_time", + "column_break_vtvv", "break_time", "stats_section", "stats_html", @@ -72,7 +76,7 @@ "hide_days": 1, "hide_seconds": 1, "in_list_view": 1, - "label": "Working Time", + "label": "Paid Working Time", "read_only": 1 }, { @@ -86,7 +90,7 @@ "fieldtype": "Duration", "hide_days": 1, "hide_seconds": 1, - "label": "Break", + "label": "Break Time", "read_only": 1 }, { @@ -142,6 +146,31 @@ { "fieldname": "section_break_cg6mr", "fieldtype": "Section Break" + }, + { + "fieldname": "section_break_cimk", + "fieldtype": "Section Break" + }, + { + "fieldname": "paid_break_time", + "fieldtype": "Duration", + "hide_days": 1, + "hide_seconds": 1, + "label": "Paid Break Time", + "read_only": 1 + }, + { + "fieldname": "column_break_vtvv", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval: doc.productive_time !== doc.working_time", + "fieldname": "productive_time", + "fieldtype": "Duration", + "hide_days": 1, + "hide_seconds": 1, + "label": "Productive Time", + "read_only": 1 } ], "grid_page_length": 50, @@ -156,7 +185,7 @@ "link_fieldname": "working_time" } ], - "modified": "2025-10-17 14:41:15.770625", + "modified": "2026-05-26 23:20:02.508838", "modified_by": "Administrator", "module": "Working Time", "name": "Working Time", @@ -193,6 +222,7 @@ } ], "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, "sort_field": "creation", "sort_order": "DESC", "states": [], diff --git a/working_time/working_time/doctype/working_time/working_time.py b/working_time/working_time/doctype/working_time/working_time.py index 549caec..3d26d7f 100644 --- a/working_time/working_time/doctype/working_time/working_time.py +++ b/working_time/working_time/doctype/working_time/working_time.py @@ -22,7 +22,8 @@ class WorkingTime(Document): def before_validate(self): - self.break_time = self.working_time = self.project_time = self.billable_time = 0 + self.break_time = self.working_time = self.productive_time = self.paid_break_time = 0 + self.project_time = self.billable_time = 0 self.project_pct = self.billable_pct = 0 last_idx = len(self.time_logs) - 1 @@ -30,11 +31,19 @@ def before_validate(self): log.to_time = self.time_logs[idx + 1].from_time if idx < last_idx else log.to_time log.cleanup_and_set_duration() log.duration = log.duration or 0 - self.break_time += log.duration if log.is_break else 0 - self.working_time += 0 if log.is_break else log.duration - if log.project and not log.is_break: - self.project_time += log.duration - self.billable_time += get_billable_duration(log) + + if log.is_break: + self.break_time += log.duration + if log.is_paid_break: + self.paid_break_time += log.duration + self.working_time += log.duration + else: + log.is_paid_break = 0 + self.productive_time += log.duration + self.working_time += log.duration + if log.project: + self.project_time += log.duration + self.billable_time += get_billable_duration(log) if self.working_time: self.project_pct = round(self.project_time / self.working_time * 100, 0) @@ -99,14 +108,14 @@ def validate_holiday_block(self, policy): ) def validate_max_working_time(self, policy): - if not policy.max_working_time_per_day: + if not policy.max_productive_time_per_day: return - if self.working_time > policy.max_working_time_per_day: + if self.productive_time > policy.max_productive_time_per_day: frappe.throw( - _("Working time ({0}) exceeds the maximum allowed ({1}) per day").format( - format_duration(self.working_time), - format_duration(policy.max_working_time_per_day), + _("Productive time ({0}) exceeds the maximum allowed ({1}) per day").format( + format_duration(self.productive_time), + format_duration(policy.max_productive_time_per_day), ) ) @@ -115,9 +124,9 @@ def validate_mandatory_breaks(self, policy): return for row in policy.mandatory_breaks: - if self.working_time >= row.work_threshold and self.break_time < row.required_break_minutes: + if self.productive_time >= row.work_threshold and self.break_time < row.required_break_minutes: frappe.throw( - _("Working time of {0} or more requires at least {1} of break time").format( + _("Productive time of {0} or more requires at least {1} of break time").format( format_duration(row.work_threshold), format_duration(row.required_break_minutes), ) diff --git a/working_time/working_time/doctype/working_time_log/working_time_log.json b/working_time/working_time/doctype/working_time_log/working_time_log.json index 80e454b..2ebe625 100644 --- a/working_time/working_time/doctype/working_time_log/working_time_log.json +++ b/working_time/working_time/doctype/working_time_log/working_time_log.json @@ -17,7 +17,8 @@ "key", "note", "section_break_9", - "is_break" + "is_break", + "is_paid_break" ], "fields": [ { @@ -101,12 +102,20 @@ "label": "Task", "link_filters": "[[\"Task\",\"project\",\"=\",\"eval: doc.project\"],[\"Task\",\"status\",\"in\",[\"Open\",\"Pending Review\",\"Working\",\"Overdue\"]]]", "options": "Task" + }, + { + "default": "0", + "depends_on": "is_break", + "description": "Enable to count this break as paid working time (e.g. mandatory travel time)", + "fieldname": "is_paid_break", + "fieldtype": "Check", + "label": "Paid" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-10-05 18:37:53.164955", + "modified": "2026-05-26 23:07:44.926553", "modified_by": "Administrator", "module": "Working Time", "name": "Working Time Log", diff --git a/working_time/working_time/doctype/working_time_policy/working_time_policy.json b/working_time/working_time/doctype/working_time_policy/working_time_policy.json index 349d262..a945748 100644 --- a/working_time/working_time/doctype/working_time_policy/working_time_policy.json +++ b/working_time/working_time/doctype/working_time_policy/working_time_policy.json @@ -6,7 +6,7 @@ "doctype": "DocType", "engine": "InnoDB", "field_order": [ - "max_working_time_per_day", + "max_productive_time_per_day", "min_rest_between_days", "section_break_qroa", "mandatory_breaks", @@ -15,13 +15,6 @@ "consider_holiday_list" ], "fields": [ - { - "fieldname": "max_working_time_per_day", - "fieldtype": "Duration", - "hide_days": 1, - "hide_seconds": 1, - "label": "Max Working Time Per Day" - }, { "fieldname": "min_rest_between_days", "fieldtype": "Duration", @@ -55,12 +48,19 @@ "fieldname": "consider_holiday_list", "fieldtype": "Check", "label": "Block holidays" + }, + { + "fieldname": "max_productive_time_per_day", + "fieldtype": "Duration", + "hide_days": 1, + "hide_seconds": 1, + "label": "Max Productive Time Per Day" } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2026-03-27 14:44:55.711799", + "modified": "2026-05-26 21:32:14.336531", "modified_by": "Administrator", "module": "Working Time", "name": "Working Time Policy",