From 8f2a7f848318d2ae253dd0b79858e5b2d485cc34 Mon Sep 17 00:00:00 2001
From: Patrick Eissler <77415730+PatrickDEissler@users.noreply.github.com>
Date: Mon, 2 Feb 2026 10:29:56 +0100
Subject: [PATCH 01/26] feat(Absence Plan): pre-note leaves
---
.../doctype/absence_plan/__init__.py | 0
.../doctype/absence_plan/absence_plan.js | 108 ++++++++
.../doctype/absence_plan/absence_plan.json | 251 ++++++++++++++++++
.../doctype/absence_plan/absence_plan.py | 106 ++++++++
.../doctype/absence_plan/absence_plan_list.js | 16 ++
.../doctype/absence_plan/test_absence_plan.py | 9 +
.../doctype/absence_plan_date/__init__.py | 0
.../absence_plan_date/absence_plan_date.json | 51 ++++
.../absence_plan_date/absence_plan_date.py | 8 +
9 files changed, 549 insertions(+)
create mode 100644 time_capture/time_capture/doctype/absence_plan/__init__.py
create mode 100644 time_capture/time_capture/doctype/absence_plan/absence_plan.js
create mode 100644 time_capture/time_capture/doctype/absence_plan/absence_plan.json
create mode 100644 time_capture/time_capture/doctype/absence_plan/absence_plan.py
create mode 100644 time_capture/time_capture/doctype/absence_plan/absence_plan_list.js
create mode 100644 time_capture/time_capture/doctype/absence_plan/test_absence_plan.py
create mode 100644 time_capture/time_capture/doctype/absence_plan_date/__init__.py
create mode 100644 time_capture/time_capture/doctype/absence_plan_date/absence_plan_date.json
create mode 100644 time_capture/time_capture/doctype/absence_plan_date/absence_plan_date.py
diff --git a/time_capture/time_capture/doctype/absence_plan/__init__.py b/time_capture/time_capture/doctype/absence_plan/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/time_capture/time_capture/doctype/absence_plan/absence_plan.js b/time_capture/time_capture/doctype/absence_plan/absence_plan.js
new file mode 100644
index 0000000..490a77b
--- /dev/null
+++ b/time_capture/time_capture/doctype/absence_plan/absence_plan.js
@@ -0,0 +1,108 @@
+// Copyright (c) 2025, ALYF GmbH and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on("Absence Plan", {
+ setup: function (frm) {
+ frm.set_query("leave_approver", function () {
+ return {
+ query: "hrms.hr.doctype.department_approver.department_approver.get_approvers",
+ filters: {
+ employee: frm.doc.employee,
+ doctype: "Leave Application",
+ },
+ };
+ });
+ },
+
+ employee: function (frm) {
+ frm.trigger("set_leave_approver");
+ },
+
+ bulk_insert_btn: function (frm) {
+ open_bulk_insert_dialog(frm);
+ },
+
+ set_leave_approver: function (frm) {
+ if (frm.doc.employee) {
+ return frappe.call({
+ method: "hrms.hr.doctype.leave_application.leave_application.get_leave_approver",
+ args: {
+ employee: frm.doc.employee,
+ },
+ callback: function (r) {
+ if (r && r.message) {
+ frm.set_value("leave_approver", r.message);
+ }
+ },
+ });
+ }
+ },
+});
+
+function open_bulk_insert_dialog(frm) {
+ const d = new frappe.ui.Dialog({
+ title: __("Bulk Insert"),
+ fields: [
+ {
+ fieldtype: "Select",
+ fieldname: "mode",
+ label: __("Mode"),
+ options: "Weekly Off\nTimespan",
+ default: "Weekly Off",
+ reqd: 1,
+ },
+ {
+ fieldtype: "Date",
+ fieldname: "from_date",
+ label: __("From Date"),
+ reqd: 1,
+ },
+ {
+ fieldtype: "Date",
+ fieldname: "to_date",
+ label: __("To Date"),
+ reqd: 1,
+ },
+ {
+ fieldtype: "Select",
+ fieldname: "weekday",
+ label: __("Weekday"),
+ options: "Monday\nTuesday\nWednesday\nThursday\nFriday\nSaturday\nSunday",
+ depends_on: "eval:doc.mode === 'Weekly Off'",
+ mandatory_depends_on: "eval:doc.mode === 'Weekly Off'",
+ },
+ {
+ fieldtype: "Small Text",
+ fieldname: "reason",
+ label: __("Reason"),
+ depends_on: "eval:doc.mode === 'Timespan'",
+ },
+ ],
+ primary_action_label: __("Add"),
+ primary_action: function (values) {
+ frappe.call({
+ method: "time_capture.time_capture.doctype.absence_plan.absence_plan.bulk_insert_dates",
+ args: {
+ doc: frm.doc,
+ mode: values.mode,
+ weekday: values.weekday,
+ from_date: values.from_date,
+ to_date: values.to_date,
+ reason: values.reason,
+ },
+ callback: function (r) {
+ if (r && r.message && r.message.length) {
+ r.message.forEach(function (item) {
+ const row = frm.add_child("dates");
+ row.date = item.date;
+ row.reason = item.reason || "";
+ });
+ frm.refresh_field("dates");
+ }
+ d.hide();
+ },
+ });
+ },
+ });
+ d.show();
+}
diff --git a/time_capture/time_capture/doctype/absence_plan/absence_plan.json b/time_capture/time_capture/doctype/absence_plan/absence_plan.json
new file mode 100644
index 0000000..98d34cd
--- /dev/null
+++ b/time_capture/time_capture/doctype/absence_plan/absence_plan.json
@@ -0,0 +1,251 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-02-02 09:00:00",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "employee",
+ "employee_name",
+ "column_break_ihkc",
+ "leave_type",
+ "section_break_ueuf",
+ "from_date",
+ "column_break_iiup",
+ "to_date",
+ "dates_section",
+ "bulk_insert_btn",
+ "dates",
+ "approval_section",
+ "leave_approver",
+ "column_break_lfyt",
+ "status",
+ "amended_from"
+ ],
+ "fields": [
+ {
+ "fieldname": "employee",
+ "fieldtype": "Link",
+ "in_filter": 1,
+ "in_list_view": 1,
+ "label": "Employee",
+ "options": "Employee",
+ "reqd": 1
+ },
+ {
+ "fetch_from": "employee.employee_name",
+ "fieldname": "employee_name",
+ "fieldtype": "Data",
+ "label": "Employee Name"
+ },
+ {
+ "fieldname": "column_break_ihkc",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "leave_type",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Leave Type",
+ "options": "Leave Type",
+ "reqd": 1
+ },
+ {
+ "fieldname": "dates_section",
+ "fieldtype": "Section Break",
+ "label": "Dates"
+ },
+ {
+ "fieldname": "bulk_insert_btn",
+ "fieldtype": "Button",
+ "label": "Bulk Insert"
+ },
+ {
+ "fieldname": "dates",
+ "fieldtype": "Table",
+ "label": "Dates",
+ "options": "Absence Plan Date"
+ },
+ {
+ "fieldname": "approval_section",
+ "fieldtype": "Section Break",
+ "label": "Approval"
+ },
+ {
+ "fieldname": "leave_approver",
+ "fieldtype": "Link",
+ "label": "Leave Approver",
+ "options": "User",
+ "reqd": 1
+ },
+ {
+ "fieldname": "column_break_lfyt",
+ "fieldtype": "Column Break"
+ },
+ {
+ "allow_on_submit": 1,
+ "fieldname": "status",
+ "fieldtype": "Select",
+ "in_filter": 1,
+ "in_standard_filter": 1,
+ "label": "Status",
+ "no_copy": 1,
+ "options": "Open\nApproved\nRejected\nCancelled",
+ "permlevel": 1
+ },
+ {
+ "fieldname": "amended_from",
+ "fieldtype": "Link",
+ "label": "Amended From",
+ "no_copy": 1,
+ "options": "Absence Plan",
+ "print_hide": 1,
+ "read_only": 1,
+ "search_index": 1
+ },
+ {
+ "fieldname": "section_break_ueuf",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "from_date",
+ "fieldtype": "Date",
+ "label": "From Date",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_iiup",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "to_date",
+ "fieldtype": "Date",
+ "label": "To Date",
+ "read_only": 1
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "is_submittable": 1,
+ "links": [],
+ "modified": "2026-02-02 10:25:00.000000",
+ "modified_by": "Administrator",
+ "module": "Time Capture",
+ "name": "Absence Plan",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "amend": 1,
+ "cancel": 1,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Employee",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "HR Manager",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ },
+ {
+ "email": 1,
+ "export": 1,
+ "permlevel": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "All",
+ "share": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "HR User",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ },
+ {
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Leave Approver",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ },
+ {
+ "email": 1,
+ "export": 1,
+ "permlevel": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Leave Approver",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "email": 1,
+ "export": 1,
+ "permlevel": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "HR User",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "email": 1,
+ "export": 1,
+ "permlevel": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "HR Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "row_format": "Dynamic",
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": [],
+ "title_field": "employee_name"
+}
diff --git a/time_capture/time_capture/doctype/absence_plan/absence_plan.py b/time_capture/time_capture/doctype/absence_plan/absence_plan.py
new file mode 100644
index 0000000..b97a284
--- /dev/null
+++ b/time_capture/time_capture/doctype/absence_plan/absence_plan.py
@@ -0,0 +1,106 @@
+# Copyright (c) 2025, ALYF GmbH and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+from frappe.utils import getdate
+from frappe.utils.data import add_to_date
+from hrms.hr.utils import share_doc_with_approver
+
+
+class AbsencePlan(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ from time_capture.time_capture.doctype.absence_plan_date.absence_plan_date import (
+ AbsencePlanDate,
+ )
+
+ amended_from: DF.Link | None
+ employee: DF.Link
+ employee_name: DF.Data | None
+ from_date: DF.Date | None
+ leave_approver: DF.Link
+ leave_type: DF.Link
+ status: DF.Literal["Open", "Approved", "Rejected", "Cancelled"]
+ dates: DF.Table[AbsencePlanDate]
+ to_date: DF.Date | None
+ # end: auto-generated types
+
+ def before_validate(self):
+ self.remove_duplicate_dates()
+ if self.dates:
+ self.from_date = min([row.date for row in self.dates])
+ self.to_date = max([row.date for row in self.dates])
+
+ def on_update(self):
+ share_doc_with_approver(self, self.leave_approver)
+
+ def remove_duplicate_dates(self):
+ """Keep only the first occurrence of each date in dates."""
+ seen = set()
+ new_rows = []
+ for row in self.dates or []:
+ key = str(row.date) if row.date else None
+ if key is None:
+ new_rows.append(row)
+ elif key not in seen:
+ seen.add(key)
+ new_rows.append(row)
+ self.dates = new_rows
+
+ def on_submit(self):
+ if self.status in ["Open", "Cancelled"]:
+ frappe.throw(_("Only Absence Plans with status 'Approved' and 'Rejected' can be submitted"))
+
+ def before_cancel(self):
+ self.status = "Cancelled"
+
+
+@frappe.whitelist()
+def bulk_insert_dates(mode, from_date, to_date, weekday=None, reason=None):
+ """Return list of {date, reason} to add. Called from Bulk Insert dialog."""
+ frappe.has_permission("Absence Plan", "write", throw=True)
+ if mode == "Weekly Off":
+ if not weekday:
+ frappe.throw(_("Please select at least one weekday"))
+ return _get_weekly_off_dates(from_date, to_date, weekday)
+ elif mode == "Timespan":
+ return _get_timespan_days(from_date, to_date, reason)
+
+ frappe.throw(_("Invalid mode"))
+
+
+def _get_weekly_off_dates(from_date, to_date, weekday):
+ weekdays = {
+ "Monday": 0,
+ "Tuesday": 1,
+ "Wednesday": 2,
+ "Thursday": 3,
+ "Friday": 4,
+ "Saturday": 5,
+ "Sunday": 6,
+ }
+ weekday_index = weekdays[weekday]
+ dates = []
+ dt = getdate(from_date)
+ while dt <= getdate(to_date):
+ if dt.weekday() == weekday_index:
+ dates.append({"date": str(dt), "reason": weekday})
+ dt = add_to_date(dt, days=1)
+ return dates
+
+
+def _get_timespan_days(from_date, to_date, reason=None):
+ dates = []
+ dt = getdate(from_date)
+ while dt <= getdate(to_date):
+ dates.append({"date": str(dt), "reason": reason})
+ dt = add_to_date(dt, days=1)
+ return dates
diff --git a/time_capture/time_capture/doctype/absence_plan/absence_plan_list.js b/time_capture/time_capture/doctype/absence_plan/absence_plan_list.js
new file mode 100644
index 0000000..6d05d98
--- /dev/null
+++ b/time_capture/time_capture/doctype/absence_plan/absence_plan_list.js
@@ -0,0 +1,16 @@
+frappe.listview_settings["Absence Plan"] = {
+ has_indicator_for_draft: 1,
+ get_indicator: function (doc) {
+ const status_color = {
+ Approved: "green",
+ Rejected: "red",
+ Open: "orange",
+ Draft: "red",
+ Cancelled: "red",
+ Submitted: "blue",
+ };
+ const status =
+ !doc.docstatus && ["Approved", "Rejected"].includes(doc.status) ? "Draft" : doc.status;
+ return [__(status), status_color[status], "status,=," + doc.status];
+ },
+};
diff --git a/time_capture/time_capture/doctype/absence_plan/test_absence_plan.py b/time_capture/time_capture/doctype/absence_plan/test_absence_plan.py
new file mode 100644
index 0000000..b587bb4
--- /dev/null
+++ b/time_capture/time_capture/doctype/absence_plan/test_absence_plan.py
@@ -0,0 +1,9 @@
+# Copyright (c) 2026, ALYF GmbH and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests.utils import FrappeTestCase
+
+
+class TestAbsencePlan(FrappeTestCase):
+ pass
diff --git a/time_capture/time_capture/doctype/absence_plan_date/__init__.py b/time_capture/time_capture/doctype/absence_plan_date/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/time_capture/time_capture/doctype/absence_plan_date/absence_plan_date.json b/time_capture/time_capture/doctype/absence_plan_date/absence_plan_date.json
new file mode 100644
index 0000000..ee8da12
--- /dev/null
+++ b/time_capture/time_capture/doctype/absence_plan_date/absence_plan_date.json
@@ -0,0 +1,51 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-02-02 09:00:00.000000",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "date",
+ "weekday",
+ "reason"
+ ],
+ "fields": [
+ {
+ "fieldname": "date",
+ "fieldtype": "Date",
+ "in_list_view": 1,
+ "label": "Date",
+ "reqd": 1
+ },
+ {
+ "fieldname": "weekday",
+ "fieldtype": "Select",
+ "label": "Weekday",
+ "options": "Monday\nTuesday\nWednesday\nThursday\nFriday\nSaturday\nSunday",
+ "read_only": 1,
+ "no_copy": 1
+ },
+ {
+ "fieldname": "reason",
+ "fieldtype": "Text Editor",
+ "in_list_view": 1,
+ "label": "Reason",
+ "description": "Examples: School or weekly off, etc."
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2026-02-02 10:25:00.000000",
+ "modified_by": "Administrator",
+ "module": "Time Capture",
+ "name": "Absence Plan Date",
+ "owner": "Administrator",
+ "permissions": [],
+ "row_format": "Dynamic",
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/time_capture/time_capture/doctype/absence_plan_date/absence_plan_date.py b/time_capture/time_capture/doctype/absence_plan_date/absence_plan_date.py
new file mode 100644
index 0000000..1c537b7
--- /dev/null
+++ b/time_capture/time_capture/doctype/absence_plan_date/absence_plan_date.py
@@ -0,0 +1,8 @@
+# Copyright (c) 2025, ALYF GmbH and contributors
+# For license information, please see license.txt
+
+from frappe.model.document import Document
+
+
+class AbsencePlanDate(Document):
+ pass
From c6001d768c2b47aa0916eb7d2681fd48126fd656 Mon Sep 17 00:00:00 2001
From: Patrick Eissler <77415730+PatrickDEissler@users.noreply.github.com>
Date: Mon, 2 Feb 2026 10:39:59 +0100
Subject: [PATCH 02/26] fix: validate sequential order of dates
---
time_capture/time_capture/doctype/absence_plan/absence_plan.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/time_capture/time_capture/doctype/absence_plan/absence_plan.py b/time_capture/time_capture/doctype/absence_plan/absence_plan.py
index b97a284..b05ba1c 100644
--- a/time_capture/time_capture/doctype/absence_plan/absence_plan.py
+++ b/time_capture/time_capture/doctype/absence_plan/absence_plan.py
@@ -67,6 +67,9 @@ def before_cancel(self):
def bulk_insert_dates(mode, from_date, to_date, weekday=None, reason=None):
"""Return list of {date, reason} to add. Called from Bulk Insert dialog."""
frappe.has_permission("Absence Plan", "write", throw=True)
+ if getdate(to_date) < getdate(from_date):
+ frappe.throw(_("To Date cannot be before From Date."))
+
if mode == "Weekly Off":
if not weekday:
frappe.throw(_("Please select at least one weekday"))
From 3ebea282e32b02acd519cd6690be9feae6935261 Mon Sep 17 00:00:00 2001
From: Patrick Eissler <77415730+PatrickDEissler@users.noreply.github.com>
Date: Mon, 2 Feb 2026 10:41:50 +0100
Subject: [PATCH 03/26] perf: minimize usage of getdate method
---
.../doctype/absence_plan/absence_plan.py | 12 +++++++-----
1 file changed, 7 insertions(+), 5 deletions(-)
diff --git a/time_capture/time_capture/doctype/absence_plan/absence_plan.py b/time_capture/time_capture/doctype/absence_plan/absence_plan.py
index b05ba1c..905a53a 100644
--- a/time_capture/time_capture/doctype/absence_plan/absence_plan.py
+++ b/time_capture/time_capture/doctype/absence_plan/absence_plan.py
@@ -67,7 +67,9 @@ def before_cancel(self):
def bulk_insert_dates(mode, from_date, to_date, weekday=None, reason=None):
"""Return list of {date, reason} to add. Called from Bulk Insert dialog."""
frappe.has_permission("Absence Plan", "write", throw=True)
- if getdate(to_date) < getdate(from_date):
+ to_date = getdate(to_date)
+ from_date = getdate(from_date)
+ if to_date < from_date:
frappe.throw(_("To Date cannot be before From Date."))
if mode == "Weekly Off":
@@ -92,8 +94,8 @@ def _get_weekly_off_dates(from_date, to_date, weekday):
}
weekday_index = weekdays[weekday]
dates = []
- dt = getdate(from_date)
- while dt <= getdate(to_date):
+ dt = from_date
+ while dt <= to_date:
if dt.weekday() == weekday_index:
dates.append({"date": str(dt), "reason": weekday})
dt = add_to_date(dt, days=1)
@@ -102,8 +104,8 @@ def _get_weekly_off_dates(from_date, to_date, weekday):
def _get_timespan_days(from_date, to_date, reason=None):
dates = []
- dt = getdate(from_date)
- while dt <= getdate(to_date):
+ dt = from_date
+ while dt <= to_date:
dates.append({"date": str(dt), "reason": reason})
dt = add_to_date(dt, days=1)
return dates
From af5ecada2078f3941c9661c605610ac95399c8e5 Mon Sep 17 00:00:00 2001
From: Patrick Eissler <77415730+PatrickDEissler@users.noreply.github.com>
Date: Mon, 2 Feb 2026 10:44:02 +0100
Subject: [PATCH 04/26] fix: avoid past dates
---
.../time_capture/doctype/absence_plan/absence_plan.py | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/time_capture/time_capture/doctype/absence_plan/absence_plan.py b/time_capture/time_capture/doctype/absence_plan/absence_plan.py
index 905a53a..2651e38 100644
--- a/time_capture/time_capture/doctype/absence_plan/absence_plan.py
+++ b/time_capture/time_capture/doctype/absence_plan/absence_plan.py
@@ -67,10 +67,12 @@ def before_cancel(self):
def bulk_insert_dates(mode, from_date, to_date, weekday=None, reason=None):
"""Return list of {date, reason} to add. Called from Bulk Insert dialog."""
frappe.has_permission("Absence Plan", "write", throw=True)
- to_date = getdate(to_date)
from_date = getdate(from_date)
+ to_date = getdate(to_date)
if to_date < from_date:
frappe.throw(_("To Date cannot be before From Date."))
+ if from_date < getdate():
+ frappe.throw(_("Dates cannot be in the past."))
if mode == "Weekly Off":
if not weekday:
From 5190661cbbb1a003060ec0764e1e5a1290dba6a4 Mon Sep 17 00:00:00 2001
From: Patrick Eissler <77415730+PatrickDEissler@users.noreply.github.com>
Date: Mon, 2 Feb 2026 10:47:45 +0100
Subject: [PATCH 05/26] feat: order dates
---
.../time_capture/doctype/absence_plan/absence_plan.py | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/time_capture/time_capture/doctype/absence_plan/absence_plan.py b/time_capture/time_capture/doctype/absence_plan/absence_plan.py
index 2651e38..3ac0e12 100644
--- a/time_capture/time_capture/doctype/absence_plan/absence_plan.py
+++ b/time_capture/time_capture/doctype/absence_plan/absence_plan.py
@@ -38,10 +38,14 @@ def before_validate(self):
if self.dates:
self.from_date = min([row.date for row in self.dates])
self.to_date = max([row.date for row in self.dates])
+ self.order_dates()
def on_update(self):
share_doc_with_approver(self, self.leave_approver)
+ def before_update_after_submit(self):
+ self.order_dates()
+
def remove_duplicate_dates(self):
"""Keep only the first occurrence of each date in dates."""
seen = set()
@@ -55,6 +59,9 @@ def remove_duplicate_dates(self):
new_rows.append(row)
self.dates = new_rows
+ def order_dates(self):
+ self.dates = sorted(self.dates, key=lambda x: x.date)
+
def on_submit(self):
if self.status in ["Open", "Cancelled"]:
frappe.throw(_("Only Absence Plans with status 'Approved' and 'Rejected' can be submitted"))
From e576e5ec53a5fd390defaaf23ae9479a741d16ff Mon Sep 17 00:00:00 2001
From: Patrick Eissler <77415730+PatrickDEissler@users.noreply.github.com>
Date: Mon, 2 Feb 2026 12:21:13 +0100
Subject: [PATCH 06/26] fix: avoid overlapping absence_plans
---
.../doctype/absence_plan/absence_plan.py | 27 ++++++++++++++++++-
1 file changed, 26 insertions(+), 1 deletion(-)
diff --git a/time_capture/time_capture/doctype/absence_plan/absence_plan.py b/time_capture/time_capture/doctype/absence_plan/absence_plan.py
index 3ac0e12..6532c5a 100644
--- a/time_capture/time_capture/doctype/absence_plan/absence_plan.py
+++ b/time_capture/time_capture/doctype/absence_plan/absence_plan.py
@@ -4,7 +4,7 @@
import frappe
from frappe import _
from frappe.model.document import Document
-from frappe.utils import getdate
+from frappe.utils import get_link_to_form, getdate
from frappe.utils.data import add_to_date
from hrms.hr.utils import share_doc_with_approver
@@ -40,11 +40,15 @@ def before_validate(self):
self.to_date = max([row.date for row in self.dates])
self.order_dates()
+ def validate(self):
+ self.avoid_overlapping_absence_plans()
+
def on_update(self):
share_doc_with_approver(self, self.leave_approver)
def before_update_after_submit(self):
self.order_dates()
+ self.avoid_overlapping_absence_plans()
def remove_duplicate_dates(self):
"""Keep only the first occurrence of each date in dates."""
@@ -62,6 +66,27 @@ def remove_duplicate_dates(self):
def order_dates(self):
self.dates = sorted(self.dates, key=lambda x: x.date)
+ def avoid_overlapping_absence_plans(self):
+ overlapping_absence_plans = frappe.db.get_all(
+ "Absence Plan",
+ {"docstatus": ["!=", 2], "employee": self.employee, "name": ["!=", self.name]},
+ or_filters={
+ "from_date": ["between", [self.from_date, self.to_date]],
+ "to_date": ["between", [self.from_date, self.to_date]],
+ },
+ )
+ if overlapping_absence_plans:
+ frappe.throw(
+ _(
+ "There is already an Absence Plan for this employee for the selected date range. See here: {0}"
+ ).format(
+ get_link_to_form(
+ "Absence Plan", overlapping_absence_plans[0].name, _("Absence Plans (List)")
+ )
+ )
+ )
+ return
+
def on_submit(self):
if self.status in ["Open", "Cancelled"]:
frappe.throw(_("Only Absence Plans with status 'Approved' and 'Rejected' can be submitted"))
From 6ee470f10dda72b5829c06890e943d2db5da6308 Mon Sep 17 00:00:00 2001
From: Patrick Eissler <77415730+PatrickDEissler@users.noreply.github.com>
Date: Mon, 2 Feb 2026 12:47:08 +0100
Subject: [PATCH 07/26] feat: add notification workflow
---
.../doctype/absence_plan/absence_plan.py | 54 +++++++++++++++++--
1 file changed, 49 insertions(+), 5 deletions(-)
diff --git a/time_capture/time_capture/doctype/absence_plan/absence_plan.py b/time_capture/time_capture/doctype/absence_plan/absence_plan.py
index 6532c5a..1fcbba0 100644
--- a/time_capture/time_capture/doctype/absence_plan/absence_plan.py
+++ b/time_capture/time_capture/doctype/absence_plan/absence_plan.py
@@ -33,6 +33,9 @@ class AbsencePlan(Document):
to_date: DF.Date | None
# end: auto-generated types
+ def after_insert(self):
+ self.notify_leave_approver()
+
def before_validate(self):
self.remove_duplicate_dates()
if self.dates:
@@ -50,6 +53,14 @@ def before_update_after_submit(self):
self.order_dates()
self.avoid_overlapping_absence_plans()
+ def on_submit(self):
+ if self.status in ["Open", "Cancelled"]:
+ frappe.throw(_("Only Absence Plans with status 'Approved' and 'Rejected' can be submitted"))
+ self.notify_employee()
+
+ def before_cancel(self):
+ self.status = "Cancelled"
+
def remove_duplicate_dates(self):
"""Keep only the first occurrence of each date in dates."""
seen = set()
@@ -87,12 +98,17 @@ def avoid_overlapping_absence_plans(self):
)
return
- def on_submit(self):
- if self.status in ["Open", "Cancelled"]:
- frappe.throw(_("Only Absence Plans with status 'Approved' and 'Rejected' can be submitted"))
+ def notify_leave_approver(self):
+ message = _("{0} raised a new Absence Plan for approval: {1}").format(
+ self.employee_name or self.employee, self.name
+ )
+ _create_pwa_notification(self.leave_approver, self.employee, "Leave Approver", message, self.name)
- def before_cancel(self):
- self.status = "Cancelled"
+ def notify_employee(self):
+ message = _("Your Absence Plan {0} has been {1}.").format(
+ self.name, _("Approved" if self.status == "Approved" else "Rejected")
+ )
+ _create_pwa_notification(self.leave_approver, self.employee, "Employee", message, self.name)
@frappe.whitelist()
@@ -143,3 +159,31 @@ def _get_timespan_days(from_date, to_date, reason=None):
dates.append({"date": str(dt), "reason": reason})
dt = add_to_date(dt, days=1)
return dates
+
+
+def _create_pwa_notification(
+ leave_approver: str, employee: str, send_to: str, message: str, absence_plan_name: str
+):
+ employee_user = frappe.db.get_value("Employee", employee, "user_id", cache=True)
+ if send_to == "Leave Approver":
+ from_user = leave_approver
+ to_user = employee_user
+ elif send_to == "Employee":
+ from_user = employee_user
+ to_user = leave_approver
+ else:
+ frappe.throw(_("Send to has to be either 'Leave Approver' or 'Employee'."))
+
+ if not employee_user or not leave_approver:
+ return
+
+ if from_user == to_user:
+ return
+
+ n = frappe.new_doc("PWA Notification")
+ n.from_user = from_user
+ n.to_user = to_user
+ n.message = message
+ n.reference_document_type = "Absence Plan"
+ n.reference_document_name = absence_plan_name
+ n.insert(ignore_permissions=True)
From 5851ccf1004e058baf7dad09cc56ebd2fc088b1c Mon Sep 17 00:00:00 2001
From: Patrick Eissler <77415730+PatrickDEissler@users.noreply.github.com>
Date: Mon, 2 Feb 2026 17:23:46 +0100
Subject: [PATCH 08/26] feat: add notification workflow
---
time_capture/locale/de.po | 103 +++++++++++++++-
time_capture/locale/main.pot | 103 +++++++++++++++-
.../doctype/absence_plan/absence_plan.json | 13 ++-
.../doctype/absence_plan/absence_plan.py | 110 ++++++++++++------
4 files changed, 285 insertions(+), 44 deletions(-)
diff --git a/time_capture/locale/de.po b/time_capture/locale/de.po
index 02da69c..28f2b4f 100644
--- a/time_capture/locale/de.po
+++ b/time_capture/locale/de.po
@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: Time Capture VERSION\n"
"Report-Msgid-Bugs-To: patrick@alyf.de\n"
-"POT-Creation-Date: 2026-01-22 17:02+0053\n"
-"PO-Revision-Date: 2025-11-07 14:07+0053\n"
+"POT-Creation-Date: 2026-02-02 17:16+0053\n"
+"PO-Revision-Date: 2025-02-02 17:08+0053\n"
"Last-Translator: patrick@alyf.de\n"
"Language-Team: patrick@alyf.de\n"
"MIME-Version: 1.0\n"
@@ -31,11 +31,45 @@ msgstr "Verwaltung"
msgid "Freelancer Time Capture"
msgstr "Freelancer Zeiterfassung"
+#. Name of a DocType
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.json
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.py:92
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.py:123
+msgid "Absence Plan"
+msgstr "Abwesenheitsplan"
+
+#. Name of a DocType
+#: time_capture/time_capture/doctype/absence_plan_date/absence_plan_date.json
+msgid "Absence Plan Date"
+msgstr "Abwesenheitsplan-Datum"
+
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.py:126
+msgid "Absence Plan {0} - {1}"
+msgstr "Abwesenheitsplan {0} - {1}"
+
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.py:95
+msgid "Absence Plan {0} for approval"
+msgstr "Abwesenheitsplan {0} zum Freigeben"
+
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.py:162
+msgid "Absence Plans (List)"
+msgstr "Abwesenheitspläne (Liste)"
+
#. Label of a Duration field in DocType 'Mandatory Break'
#: time_capture/time_capture/doctype/mandatory_break/mandatory_break.json
msgid "Additional Break"
msgstr "Zusätzliche Pause"
+#. Label of a Section Break field in DocType 'Absence Plan'
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.json
+msgid "Approval"
+msgstr "Freigabe"
+
+#. Option for the 'Status' (Select) field in DocType 'Absence Plan'
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.json
+msgid "Approved"
+msgstr "Genehmigt"
+
#: time_capture/time_capture/report/working_time/working_time.js:24
msgid "April"
msgstr "April"
@@ -58,6 +92,12 @@ msgstr "August"
msgid "Break"
msgstr "Pause"
+#. Label of a Button field in DocType 'Absence Plan'
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.js:44
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.json
+msgid "Bulk Insert"
+msgstr "Massen einfügen"
+
#: time_capture/custom_fields.py:60 time_capture/public/js/employee.js:29
msgid "Change/Add Expected Hours"
msgstr "Erwartete Stunden ändern/hinzufügen"
@@ -103,6 +143,10 @@ msgstr "Freelancer-Benutzer wird erstellt"
msgid "Current Balance"
msgstr "Aktuelles Saldo"
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.py:178
+msgid "Dates cannot be in the past."
+msgstr "Daten dürfen nicht in der Vergangenheit liegen."
+
#: time_capture/time_capture/report/working_time/working_time.js:32
msgid "December"
msgstr "Dezember"
@@ -126,6 +170,11 @@ msgstr "Entwürfe"
msgid "Duplicate Entry Found"
msgstr "Doppelter Eintrag gefunden"
+#. Label of a Data field in DocType 'Absence Plan'
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.json
+msgid "Employee Email"
+msgstr "Mitarbeiter-E-Mail"
+
#. Name of a DocType
#: time_capture/time_capture/doctype/employee_expected_working_hours/employee_expected_working_hours.json
msgid "Employee Expected Working Hours"
@@ -140,6 +189,10 @@ msgstr "Mitarbeiterstatus"
msgid "Employee {0} has Attendances outside the Holiday List period."
msgstr "Mitarbeiter {0} hat Anwesenheiten außerhalb der Periode der Feiertagsliste."
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.py:129
+msgid "Employee {0} has been notified via Email."
+msgstr "Mitarbeiter {0} wurde per E-Mail benachrichtigt."
+
#. Label of a Check field in DocType 'Time Capture Settings'
#: time_capture/time_capture/doctype/time_capture_settings/time_capture_settings.json
msgid "Enable Reminders"
@@ -170,6 +223,12 @@ msgstr "Alle X Tage"
msgid "Example: If you set this field to '3', the system sends Notifications every 3 days."
msgstr "Beispiel: Wenn Sie dieses Feld auf '3' setzen, sendet das System alle 3 Tage Benachrichtigungen."
+#. Description of the 'Reason' (Text Editor) field in DocType 'Absence Plan
+#. Date'
+#: time_capture/time_capture/doctype/absence_plan_date/absence_plan_date.json
+msgid "Examples: School or weekly off, etc."
+msgstr "Beispiele: Schule, wöchentlicher freier Tag usw."
+
#. Label of a Float field in DocType 'Employee Expected Working Hours'
#: time_capture/time_capture/doctype/employee_expected_working_hours/employee_expected_working_hours.json
msgid "Expected Daily Working Hours"
@@ -292,6 +351,10 @@ msgstr "Angegebene Pause"
msgid "Indicated Break is not in the proper format."
msgstr "Angegebene Pause ist nicht im korrekten Format."
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.py:187
+msgid "Invalid mode"
+msgstr "Ungültiger Modus"
+
#. Label of a Check field in DocType 'Time Capture'
#: time_capture/time_capture/doctype/time_capture/time_capture.json
msgid "Is Of Legal Age"
@@ -318,6 +381,12 @@ msgstr "Letzte manuelle Saldo-Korrektur"
msgid "Last Notification (Sent On)"
msgstr "Letzte Benachrichtigung (gesendet am)"
+#. Label of a Link field in DocType 'Absence Plan'
+#. Name of a role
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.json
+msgid "Leave Approver"
+msgstr "Urlaubsgenehmiger"
+
#: time_capture/custom_fields.py:65
msgid "Leave Policy"
msgstr "Urlaubsrichtlinie"
@@ -330,7 +399,9 @@ msgstr "Urlaubsrichtlinie wurde geändert. Hinweis: Dies aktualisiert keine best
msgid "Leave Summary"
msgstr "Urlaubsübersicht"
+#. Label of a Link field in DocType 'Absence Plan'
#: time_capture/public/js/utils.js:143
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.json
msgid "Leave Type"
msgstr "Urlaubstyp"
@@ -339,6 +410,10 @@ msgstr "Urlaubstyp"
msgid "Leave and Working Time Summaries"
msgstr "Urlaubs- und Arbeitszeitübersichten"
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.py:98
+msgid "Leave approver {0} has been notified via Email."
+msgstr "Urlaubsgenehmiger {0} wurde per E-Mail benachrichtigt."
+
#. Label of a Section Break field in DocType 'Time Capture Settings'
#: time_capture/time_capture/doctype/time_capture_settings/time_capture_settings.json
msgid "Leaves"
@@ -375,6 +450,10 @@ msgstr "Mai"
msgid "Minimum Draft Age (Days)"
msgstr "Mindestalter für Entwürfe (Tage)"
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.js:49
+msgid "Mode"
+msgstr "Modus"
+
#: time_capture/custom_fields.py:41
msgid "No Supervisor Required"
msgstr "Kein Vorgesetzter Benötigt"
@@ -435,6 +514,10 @@ msgstr "November"
msgid "October"
msgstr "Oktober"
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.py:62
+msgid "Only Absence Plans with status 'Approved' and 'Rejected' can be submitted"
+msgstr "Nur Abwesenheitspläne mit Status 'Genehmigt' und 'Abgelehnt' können gebucht werden."
+
#: time_capture/public/js/employee.js:11
msgid "Only System Manager are allowed to edit Expected Working Hours."
msgstr "Nur System Manager dürfen Erwartete Arbeitsstunden anpassen."
@@ -486,6 +569,10 @@ msgstr "Bitte überprüfen und buchen Sie die ausstehenden Zeiterfassungen."
msgid "Please save the Employee first."
msgstr "Bitte speichern Sie den Mitarbeiter zuerst."
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.py:182
+msgid "Please select at least one weekday"
+msgstr "Bitte wählen Sie mindestens einen Wochentag."
+
#: time_capture/time_capture/doctype/create_freelancer/create_freelancer.js:18
msgid "Please wait..."
msgstr "Bitte warten..."
@@ -579,6 +666,10 @@ msgstr "Der Vorgesetzte kann nicht gleich dem Mitarbeiter sein."
msgid "The supervisor has no User linked in his/her Employee record."
msgstr "Der Vorgesetzte hat keinen Nutzer in seinem/ihren Mitarbeiter-Eintrag."
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.py:158
+msgid "There is already an Absence Plan for this employee for the selected date range. See here: {0}"
+msgstr "Es existiert bereits ein Abwesenheitsplan für diesen Mitarbeiter im gewählten Zeitraum. Siehe hier: {0}"
+
#: time_capture/scripts/employee.py:87
msgid "This Employee has no supervisor (Reports To), but has Create User Permission checkbox enabled. Please, make sure that this intended."
msgstr "Dieser Mitarbeiter hat keinen Vorgesetzten, aber er hat Benutzerberechtigung Erstellen aktiviert. Bitte prüfen Sie, ob das gewollt ist."
@@ -673,6 +764,10 @@ msgstr "Arbeitszeitübersicht"
msgid "Working time must be completely booked on projects and tasks."
msgstr "Die Arbeitszeit muss vollständig auf Projekte und Aufgaben gebucht werden."
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.py:113
+msgid "Your Absence Plan {0} has been {1}."
+msgstr "Ihr Abwesenheitsplan {0} wurde {1}"
+
#: time_capture/scripts/summary_utils.py:109
msgid "{0} (on {1})"
msgstr "{0} (am {1})"
@@ -685,3 +780,7 @@ msgstr "{0} Attendances erfolgreich aktualisiert."
msgid "{0} already exists for this date and person."
msgstr "{0} existiert bereits für dieses Datum und diese Person."
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.py:80
+msgid "{0} raised a new Absence Plan for approval: {1}"
+msgstr "{0} hat einen neuen Abwesenheitsplan zum Freigeben erstellt: {1}"
+
diff --git a/time_capture/locale/main.pot b/time_capture/locale/main.pot
index 0e2f2f5..6002e23 100644
--- a/time_capture/locale/main.pot
+++ b/time_capture/locale/main.pot
@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: Time Capture VERSION\n"
"Report-Msgid-Bugs-To: patrick@alyf.de\n"
-"POT-Creation-Date: 2026-01-22 17:02+0053\n"
-"PO-Revision-Date: 2026-01-22 17:02+0053\n"
+"POT-Creation-Date: 2026-02-02 17:16+0053\n"
+"PO-Revision-Date: 2026-02-02 17:16+0053\n"
"Last-Translator: patrick@alyf.de\n"
"Language-Team: patrick@alyf.de\n"
"MIME-Version: 1.0\n"
@@ -31,11 +31,45 @@ msgstr ""
msgid "Freelancer Time Capture"
msgstr ""
+#. Name of a DocType
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.json
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.py:92
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.py:123
+msgid "Absence Plan"
+msgstr ""
+
+#. Name of a DocType
+#: time_capture/time_capture/doctype/absence_plan_date/absence_plan_date.json
+msgid "Absence Plan Date"
+msgstr ""
+
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.py:126
+msgid "Absence Plan {0} - {1}"
+msgstr ""
+
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.py:95
+msgid "Absence Plan {0} for approval"
+msgstr ""
+
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.py:162
+msgid "Absence Plans (List)"
+msgstr ""
+
#. Label of a Duration field in DocType 'Mandatory Break'
#: time_capture/time_capture/doctype/mandatory_break/mandatory_break.json
msgid "Additional Break"
msgstr ""
+#. Label of a Section Break field in DocType 'Absence Plan'
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.json
+msgid "Approval"
+msgstr ""
+
+#. Option for the 'Status' (Select) field in DocType 'Absence Plan'
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.json
+msgid "Approved"
+msgstr ""
+
#: time_capture/time_capture/report/working_time/working_time.js:24
msgid "April"
msgstr ""
@@ -58,6 +92,12 @@ msgstr ""
msgid "Break"
msgstr ""
+#. Label of a Button field in DocType 'Absence Plan'
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.js:44
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.json
+msgid "Bulk Insert"
+msgstr ""
+
#: time_capture/custom_fields.py:60 time_capture/public/js/employee.js:29
msgid "Change/Add Expected Hours"
msgstr ""
@@ -103,6 +143,10 @@ msgstr ""
msgid "Current Balance"
msgstr ""
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.py:178
+msgid "Dates cannot be in the past."
+msgstr ""
+
#: time_capture/time_capture/report/working_time/working_time.js:32
msgid "December"
msgstr ""
@@ -126,6 +170,11 @@ msgstr ""
msgid "Duplicate Entry Found"
msgstr ""
+#. Label of a Data field in DocType 'Absence Plan'
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.json
+msgid "Employee Email"
+msgstr ""
+
#. Name of a DocType
#: time_capture/time_capture/doctype/employee_expected_working_hours/employee_expected_working_hours.json
msgid "Employee Expected Working Hours"
@@ -140,6 +189,10 @@ msgstr ""
msgid "Employee {0} has Attendances outside the Holiday List period."
msgstr ""
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.py:129
+msgid "Employee {0} has been notified via Email."
+msgstr ""
+
#. Label of a Check field in DocType 'Time Capture Settings'
#: time_capture/time_capture/doctype/time_capture_settings/time_capture_settings.json
msgid "Enable Reminders"
@@ -170,6 +223,12 @@ msgstr ""
msgid "Example: If you set this field to '3', the system sends Notifications every 3 days."
msgstr ""
+#. Description of the 'Reason' (Text Editor) field in DocType 'Absence Plan
+#. Date'
+#: time_capture/time_capture/doctype/absence_plan_date/absence_plan_date.json
+msgid "Examples: School or weekly off, etc."
+msgstr ""
+
#. Label of a Float field in DocType 'Employee Expected Working Hours'
#: time_capture/time_capture/doctype/employee_expected_working_hours/employee_expected_working_hours.json
msgid "Expected Daily Working Hours"
@@ -292,6 +351,10 @@ msgstr ""
msgid "Indicated Break is not in the proper format."
msgstr ""
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.py:187
+msgid "Invalid mode"
+msgstr ""
+
#. Label of a Check field in DocType 'Time Capture'
#: time_capture/time_capture/doctype/time_capture/time_capture.json
msgid "Is Of Legal Age"
@@ -318,6 +381,12 @@ msgstr ""
msgid "Last Notification (Sent On)"
msgstr ""
+#. Label of a Link field in DocType 'Absence Plan'
+#. Name of a role
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.json
+msgid "Leave Approver"
+msgstr ""
+
#: time_capture/custom_fields.py:65
msgid "Leave Policy"
msgstr ""
@@ -330,7 +399,9 @@ msgstr ""
msgid "Leave Summary"
msgstr ""
+#. Label of a Link field in DocType 'Absence Plan'
#: time_capture/public/js/utils.js:143
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.json
msgid "Leave Type"
msgstr ""
@@ -339,6 +410,10 @@ msgstr ""
msgid "Leave and Working Time Summaries"
msgstr ""
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.py:98
+msgid "Leave approver {0} has been notified via Email."
+msgstr ""
+
#. Label of a Section Break field in DocType 'Time Capture Settings'
#: time_capture/time_capture/doctype/time_capture_settings/time_capture_settings.json
msgid "Leaves"
@@ -375,6 +450,10 @@ msgstr ""
msgid "Minimum Draft Age (Days)"
msgstr ""
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.js:49
+msgid "Mode"
+msgstr ""
+
#: time_capture/custom_fields.py:41
msgid "No Supervisor Required"
msgstr ""
@@ -435,6 +514,10 @@ msgstr ""
msgid "October"
msgstr ""
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.py:62
+msgid "Only Absence Plans with status 'Approved' and 'Rejected' can be submitted"
+msgstr ""
+
#: time_capture/public/js/employee.js:11
msgid "Only System Manager are allowed to edit Expected Working Hours."
msgstr ""
@@ -486,6 +569,10 @@ msgstr ""
msgid "Please save the Employee first."
msgstr ""
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.py:182
+msgid "Please select at least one weekday"
+msgstr ""
+
#: time_capture/time_capture/doctype/create_freelancer/create_freelancer.js:18
msgid "Please wait..."
msgstr ""
@@ -579,6 +666,10 @@ msgstr ""
msgid "The supervisor has no User linked in his/her Employee record."
msgstr ""
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.py:158
+msgid "There is already an Absence Plan for this employee for the selected date range. See here: {0}"
+msgstr ""
+
#: time_capture/scripts/employee.py:87
msgid "This Employee has no supervisor (Reports To), but has Create User Permission checkbox enabled. Please, make sure that this intended."
msgstr ""
@@ -673,6 +764,10 @@ msgstr ""
msgid "Working time must be completely booked on projects and tasks."
msgstr ""
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.py:113
+msgid "Your Absence Plan {0} has been {1}."
+msgstr ""
+
#: time_capture/scripts/summary_utils.py:109
msgid "{0} (on {1})"
msgstr ""
@@ -685,3 +780,7 @@ msgstr ""
msgid "{0} already exists for this date and person."
msgstr ""
+#: time_capture/time_capture/doctype/absence_plan/absence_plan.py:80
+msgid "{0} raised a new Absence Plan for approval: {1}"
+msgstr ""
+
diff --git a/time_capture/time_capture/doctype/absence_plan/absence_plan.json b/time_capture/time_capture/doctype/absence_plan/absence_plan.json
index 98d34cd..94ee719 100644
--- a/time_capture/time_capture/doctype/absence_plan/absence_plan.json
+++ b/time_capture/time_capture/doctype/absence_plan/absence_plan.json
@@ -7,6 +7,7 @@
"field_order": [
"employee",
"employee_name",
+ "employee_email",
"column_break_ihkc",
"leave_type",
"section_break_ueuf",
@@ -30,6 +31,7 @@
"in_list_view": 1,
"label": "Employee",
"options": "Employee",
+ "read_only_depends_on": "eval: !doc.__islocal;",
"reqd": 1
},
{
@@ -122,13 +124,20 @@
"fieldtype": "Date",
"label": "To Date",
"read_only": 1
+ },
+ {
+ "fieldname": "employee_email",
+ "fieldtype": "Data",
+ "hidden": 1,
+ "label": "Employee Email",
+ "options": "Email"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2026-02-02 10:25:00.000000",
+ "modified": "2026-02-02 16:52:02.277175",
"modified_by": "Administrator",
"module": "Time Capture",
"name": "Absence Plan",
@@ -248,4 +257,4 @@
"sort_order": "DESC",
"states": [],
"title_field": "employee_name"
-}
+}
\ No newline at end of file
diff --git a/time_capture/time_capture/doctype/absence_plan/absence_plan.py b/time_capture/time_capture/doctype/absence_plan/absence_plan.py
index 1fcbba0..26eef6f 100644
--- a/time_capture/time_capture/doctype/absence_plan/absence_plan.py
+++ b/time_capture/time_capture/doctype/absence_plan/absence_plan.py
@@ -7,6 +7,7 @@
from frappe.utils import get_link_to_form, getdate
from frappe.utils.data import add_to_date
from hrms.hr.utils import share_doc_with_approver
+from hrms.utils import get_employee_email
class AbsencePlan(Document):
@@ -33,6 +34,9 @@ class AbsencePlan(Document):
to_date: DF.Date | None
# end: auto-generated types
+ def before_insert(self):
+ self.employee_email = get_employee_email(self.employee)
+
def after_insert(self):
self.notify_leave_approver()
@@ -61,6 +65,69 @@ def on_submit(self):
def before_cancel(self):
self.status = "Cancelled"
+ def notify_leave_approver(self):
+ """
+ Notify leave approver about new Absence Plan
+ """
+ # Preperation
+ if not self.leave_approver:
+ return
+ from_user = self.employee_email
+ if from_user == self.leave_approver:
+ return
+
+ lang = _get_user_lang(self.leave_approver)
+ message = _("{0} raised a new Absence Plan for approval: {1}", lang=lang).format(
+ self.employee_name or self.employee, self.name
+ )
+
+ # Create Notification
+ n = frappe.new_doc("PWA Notification")
+ n.from_user, n.to_user = from_user, self.leave_approver
+ n.message = message
+ n.reference_document_type, n.reference_document_name = self.doctype, self.name
+ n.insert(ignore_permissions=True)
+
+ # Send Email
+ link = get_link_to_form(self.doctype, self.name, _("Absence Plan", lang=lang))
+ frappe.sendmail(
+ recipients=[self.leave_approver],
+ subject=_("Absence Plan {0} for approval", lang=lang).format(self.name),
+ message=message + "
" + link,
+ )
+ frappe.msgprint(_("Leave approver {0} has been notified via Email.").format(self.leave_approver))
+
+ def notify_employee(self):
+ """
+ Notify employee about Absence Plan approval status
+ """
+ # Preperation
+ to_user = self.employee_email
+ if not to_user:
+ return
+ from_user = self.leave_approver
+ if from_user == to_user:
+ return
+
+ lang = _get_user_lang(to_user)
+ message = _("Your Absence Plan {0} has been {1}.", lang=lang).format(self.name, self.status)
+
+ # Create Notification
+ n = frappe.new_doc("PWA Notification")
+ n.from_user, n.to_user = from_user, to_user
+ n.message = message
+ n.reference_document_type, n.reference_document_name = self.doctype, self.name
+ n.insert(ignore_permissions=True)
+
+ # Send Email
+ link = get_link_to_form(self.doctype, self.name, _("Absence Plan", lang=lang))
+ frappe.sendmail(
+ recipients=[to_user],
+ subject=_("Absence Plan {0} - {1}", lang=lang).format(self.name, self.status),
+ message=message + "
" + link,
+ )
+ frappe.msgprint(_("Employee {0} has been notified via Email.").format(to_user))
+
def remove_duplicate_dates(self):
"""Keep only the first occurrence of each date in dates."""
seen = set()
@@ -98,18 +165,6 @@ def avoid_overlapping_absence_plans(self):
)
return
- def notify_leave_approver(self):
- message = _("{0} raised a new Absence Plan for approval: {1}").format(
- self.employee_name or self.employee, self.name
- )
- _create_pwa_notification(self.leave_approver, self.employee, "Leave Approver", message, self.name)
-
- def notify_employee(self):
- message = _("Your Absence Plan {0} has been {1}.").format(
- self.name, _("Approved" if self.status == "Approved" else "Rejected")
- )
- _create_pwa_notification(self.leave_approver, self.employee, "Employee", message, self.name)
-
@frappe.whitelist()
def bulk_insert_dates(mode, from_date, to_date, weekday=None, reason=None):
@@ -161,29 +216,8 @@ def _get_timespan_days(from_date, to_date, reason=None):
return dates
-def _create_pwa_notification(
- leave_approver: str, employee: str, send_to: str, message: str, absence_plan_name: str
-):
- employee_user = frappe.db.get_value("Employee", employee, "user_id", cache=True)
- if send_to == "Leave Approver":
- from_user = leave_approver
- to_user = employee_user
- elif send_to == "Employee":
- from_user = employee_user
- to_user = leave_approver
- else:
- frappe.throw(_("Send to has to be either 'Leave Approver' or 'Employee'."))
-
- if not employee_user or not leave_approver:
- return
-
- if from_user == to_user:
- return
-
- n = frappe.new_doc("PWA Notification")
- n.from_user = from_user
- n.to_user = to_user
- n.message = message
- n.reference_document_type = "Absence Plan"
- n.reference_document_name = absence_plan_name
- n.insert(ignore_permissions=True)
+def _get_user_lang(user):
+ user_lang = frappe.db.get_value("User", user, "language", cache=True)
+ if user_lang:
+ return user_lang
+ return frappe.local.lang or "en"
From 4520c64de47955bf20fc90099379a0f0144c0ce9 Mon Sep 17 00:00:00 2001
From: Patrick Eissler <77415730+PatrickDEissler@users.noreply.github.com>
Date: Mon, 2 Feb 2026 18:12:53 +0100
Subject: [PATCH 09/26] feat: allow several plans, without update after submit
---
.../doctype/absence_plan/absence_plan.js | 2 +-
.../doctype/absence_plan/absence_plan.json | 8 +++--
.../doctype/absence_plan/absence_plan.py | 35 +++----------------
3 files changed, 11 insertions(+), 34 deletions(-)
diff --git a/time_capture/time_capture/doctype/absence_plan/absence_plan.js b/time_capture/time_capture/doctype/absence_plan/absence_plan.js
index 490a77b..db5fab9 100644
--- a/time_capture/time_capture/doctype/absence_plan/absence_plan.js
+++ b/time_capture/time_capture/doctype/absence_plan/absence_plan.js
@@ -83,7 +83,6 @@ function open_bulk_insert_dialog(frm) {
frappe.call({
method: "time_capture.time_capture.doctype.absence_plan.absence_plan.bulk_insert_dates",
args: {
- doc: frm.doc,
mode: values.mode,
weekday: values.weekday,
from_date: values.from_date,
@@ -98,6 +97,7 @@ function open_bulk_insert_dialog(frm) {
row.reason = item.reason || "";
});
frm.refresh_field("dates");
+ frm.save();
}
d.hide();
},
diff --git a/time_capture/time_capture/doctype/absence_plan/absence_plan.json b/time_capture/time_capture/doctype/absence_plan/absence_plan.json
index 94ee719..abf6a41 100644
--- a/time_capture/time_capture/doctype/absence_plan/absence_plan.json
+++ b/time_capture/time_capture/doctype/absence_plan/absence_plan.json
@@ -60,13 +60,15 @@
{
"fieldname": "bulk_insert_btn",
"fieldtype": "Button",
- "label": "Bulk Insert"
+ "label": "Bulk Insert",
+ "depends_on": "eval: doc.docstatus == 0"
},
{
"fieldname": "dates",
"fieldtype": "Table",
"label": "Dates",
- "options": "Absence Plan Date"
+ "options": "Absence Plan Date",
+ "read_only_depends_on": ""
},
{
"fieldname": "approval_section",
@@ -137,7 +139,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2026-02-02 16:52:02.277175",
+ "modified": "2026-02-02 17:59:00.281409",
"modified_by": "Administrator",
"module": "Time Capture",
"name": "Absence Plan",
diff --git a/time_capture/time_capture/doctype/absence_plan/absence_plan.py b/time_capture/time_capture/doctype/absence_plan/absence_plan.py
index 26eef6f..5b44f78 100644
--- a/time_capture/time_capture/doctype/absence_plan/absence_plan.py
+++ b/time_capture/time_capture/doctype/absence_plan/absence_plan.py
@@ -1,6 +1,8 @@
# Copyright (c) 2025, ALYF GmbH and contributors
# For license information, please see license.txt
+import json
+
import frappe
from frappe import _
from frappe.model.document import Document
@@ -47,16 +49,9 @@ def before_validate(self):
self.to_date = max([row.date for row in self.dates])
self.order_dates()
- def validate(self):
- self.avoid_overlapping_absence_plans()
-
def on_update(self):
share_doc_with_approver(self, self.leave_approver)
- def before_update_after_submit(self):
- self.order_dates()
- self.avoid_overlapping_absence_plans()
-
def on_submit(self):
if self.status in ["Open", "Cancelled"]:
frappe.throw(_("Only Absence Plans with status 'Approved' and 'Rejected' can be submitted"))
@@ -144,32 +139,12 @@ def remove_duplicate_dates(self):
def order_dates(self):
self.dates = sorted(self.dates, key=lambda x: x.date)
- def avoid_overlapping_absence_plans(self):
- overlapping_absence_plans = frappe.db.get_all(
- "Absence Plan",
- {"docstatus": ["!=", 2], "employee": self.employee, "name": ["!=", self.name]},
- or_filters={
- "from_date": ["between", [self.from_date, self.to_date]],
- "to_date": ["between", [self.from_date, self.to_date]],
- },
- )
- if overlapping_absence_plans:
- frappe.throw(
- _(
- "There is already an Absence Plan for this employee for the selected date range. See here: {0}"
- ).format(
- get_link_to_form(
- "Absence Plan", overlapping_absence_plans[0].name, _("Absence Plans (List)")
- )
- )
- )
- return
-
@frappe.whitelist()
-def bulk_insert_dates(mode, from_date, to_date, weekday=None, reason=None):
+def bulk_insert_dates(
+ mode: str, from_date: str, to_date: str, weekday: str | None = None, reason: str | None = None
+):
"""Return list of {date, reason} to add. Called from Bulk Insert dialog."""
- frappe.has_permission("Absence Plan", "write", throw=True)
from_date = getdate(from_date)
to_date = getdate(to_date)
if to_date < from_date:
From c166f8b54f7e6335e5d48d2cf0fc9eebe476f5dd Mon Sep 17 00:00:00 2001
From: Patrick Eissler <77415730+PatrickDEissler@users.noreply.github.com>
Date: Mon, 2 Feb 2026 18:20:36 +0100
Subject: [PATCH 10/26] fix: add depends_on statements
---
.../doctype/absence_plan/absence_plan.json | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/time_capture/time_capture/doctype/absence_plan/absence_plan.json b/time_capture/time_capture/doctype/absence_plan/absence_plan.json
index abf6a41..ab0e501 100644
--- a/time_capture/time_capture/doctype/absence_plan/absence_plan.json
+++ b/time_capture/time_capture/doctype/absence_plan/absence_plan.json
@@ -58,17 +58,16 @@
"label": "Dates"
},
{
+ "depends_on": "eval: doc.docstatus == 0",
"fieldname": "bulk_insert_btn",
"fieldtype": "Button",
- "label": "Bulk Insert",
- "depends_on": "eval: doc.docstatus == 0"
+ "label": "Bulk Insert"
},
{
"fieldname": "dates",
"fieldtype": "Table",
"label": "Dates",
- "options": "Absence Plan Date",
- "read_only_depends_on": ""
+ "options": "Absence Plan Date"
},
{
"fieldname": "approval_section",
@@ -95,7 +94,8 @@
"label": "Status",
"no_copy": 1,
"options": "Open\nApproved\nRejected\nCancelled",
- "permlevel": 1
+ "permlevel": 1,
+ "read_only_depends_on": "eval: doc.docstatus == 1"
},
{
"fieldname": "amended_from",
@@ -139,7 +139,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2026-02-02 17:59:00.281409",
+ "modified": "2026-02-02 18:18:31.040154",
"modified_by": "Administrator",
"module": "Time Capture",
"name": "Absence Plan",
From 667a2a79aeffff340c1d8f375edcca82db8e5156 Mon Sep 17 00:00:00 2001
From: Patrick Eissler <77415730+PatrickDEissler@users.noreply.github.com>
Date: Tue, 3 Feb 2026 09:38:23 +0100
Subject: [PATCH 11/26] feat: consider Absence Plan when creating Time Captures
---
time_capture/custom_fields.py | 18 ++++++++
time_capture/patches.txt | 2 +-
time_capture/scripts/attendance.py | 15 +++----
.../doctype/time_capture/time_capture.py | 45 +++++++++++++++++++
4 files changed, 70 insertions(+), 10 deletions(-)
diff --git a/time_capture/custom_fields.py b/time_capture/custom_fields.py
index 66ca71c..d35b4c5 100644
--- a/time_capture/custom_fields.py
+++ b/time_capture/custom_fields.py
@@ -24,6 +24,24 @@ def get_custom_fields():
"precision": "2",
"allow_on_submit": 1,
},
+ {
+ "fieldname": "custom_absence_plan",
+ "fieldtype": "Link",
+ "insert_after": "leave_application",
+ "label": _("Absence Plan"),
+ "no_copy": 1,
+ "options": "Absence Plan",
+ "read_only": 1,
+ },
+ {
+ "fieldname": "custom_leave_type_absence_plan",
+ "fieldtype": "Link",
+ "insert_after": "custom_absence_plan",
+ "label": _("Leave Type (Absence Plan)"),
+ "no_copy": 1,
+ "options": "Leave Type",
+ "read_only": 1,
+ },
{
"label": _("Time Capture"),
"fieldname": "custom_time_capture",
diff --git a/time_capture/patches.txt b/time_capture/patches.txt
index fd3ee43..b633e73 100644
--- a/time_capture/patches.txt
+++ b/time_capture/patches.txt
@@ -3,7 +3,7 @@
# Read docs to understand patches: https://frappeframework.com/docs/v14/user/en/database-migrations
[post_model_sync]
-execute:from time_capture.install import _make_custom_fields;_make_custom_fields() # 2026-01-22 #2
+execute:from time_capture.install import _make_custom_fields;_make_custom_fields() # 2026-02-03 #1
execute:from time_capture.install import _make_property_setters;_make_property_setters() # 2026-01-22 #2
time_capture.patches.move_expected_working_time_to_child_table
time_capture.patches.create_freelancer_role #7
diff --git a/time_capture/scripts/attendance.py b/time_capture/scripts/attendance.py
index ad503d1..4528ee6 100644
--- a/time_capture/scripts/attendance.py
+++ b/time_capture/scripts/attendance.py
@@ -9,7 +9,7 @@
def on_change(doc, event):
set_attendance_metrics(doc)
if (
- doc.leave_type
+ (doc.leave_type or doc.custom_leave_type_absence_plan)
and frappe.utils.getdate(doc.attendance_date) <= frappe.utils.getdate()
and doc.docstatus != 2
):
@@ -52,9 +52,8 @@ def get_attendance_metrics(doc, get_working_hours=False):
Returns:
tuple: (status, actual_working_hours, expected_working_hours, flexitime)
"""
- if doc.leave_type and doc.leave_type == frappe.db.get_single_value(
- "Time Capture Settings", "sick_leave_type"
- ):
+ leave_type = doc.leave_type or doc.custom_leave_type_absence_plan
+ if leave_type and leave_type == frappe.db.get_single_value("Time Capture Settings", "sick_leave_type"):
# Special Case: Sick Leaves are not considered for working hours or expected working hours.
return "On Leave", 0.0, 0.0, 0.0
@@ -70,14 +69,12 @@ def get_attendance_metrics(doc, get_working_hours=False):
working_hours = doc.working_hours or 0.0
# 2: Get Status
- status = _get_attendance_status(
- is_half_day, expected_working_hours_full_day, working_hours, doc.leave_type
- )
+ status = _get_attendance_status(is_half_day, expected_working_hours_full_day, working_hours, leave_type)
# 3: Get Expected Working Hours
- if doc.leave_type and doc.leave_application:
+ if leave_type:
expected_working_hours = _get_expected_working_hours_for_leave_days(
- expected_working_hours_full_day, doc.leave_type, is_half_day
+ expected_working_hours_full_day, leave_type, is_half_day
)
else:
expected_working_hours = expected_working_hours_full_day
diff --git a/time_capture/time_capture/doctype/time_capture/time_capture.py b/time_capture/time_capture/doctype/time_capture/time_capture.py
index d6fac80..3150d5b 100644
--- a/time_capture/time_capture/doctype/time_capture/time_capture.py
+++ b/time_capture/time_capture/doctype/time_capture/time_capture.py
@@ -308,6 +308,9 @@ def _create_time_capture(employee, date):
if is_holiday(employee.holiday_list, date):
return
+ if _create_attendance_for_absence_plan(employee.name, date):
+ return
+
time_capture = frappe.new_doc("Time Capture")
time_capture.update(
{
@@ -320,6 +323,48 @@ def _create_time_capture(employee, date):
time_capture.insert()
+def _create_attendance_for_absence_plan(employee_name, date):
+ """
+ Check if the employee has an approved Absence Plan that includes the given date.
+ Returns the leave_type from the matching plan, or None if none found.
+ Caller is responsible for attendance creation etc.
+ """
+ # Get Absence Plan and Leave Type from Absence Plan
+ AP = frappe.qb.DocType("Absence Plan")
+ APD = frappe.qb.DocType("Absence Plan Date")
+ absence_plans = (
+ frappe.qb.from_(AP)
+ .inner_join(APD)
+ .on(AP.name == APD.parent)
+ .select(AP.name, AP.leave_type)
+ .where(
+ (AP.employee == employee_name)
+ & (APD.date == date)
+ & (AP.status == "Approved")
+ & (AP.docstatus == 1)
+ )
+ ).run(as_dict=True)
+
+ if not absence_plans:
+ return False
+
+ # Create Attendance if there is a leave type (= absence is planned)
+ absence_plan = absence_plans[0]
+ attendance = frappe.get_doc(
+ {
+ "doctype": "Attendance",
+ "employee": employee_name,
+ "attendance_date": date,
+ "custom_absence_plan": absence_plan.name,
+ "custom_leave_type_absence_plan": absence_plan.leave_type,
+ }
+ )
+ attendance.insert()
+ attendance.submit()
+
+ return True
+
+
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def task_query(doctype, txt, searchfield, start, page_len, filters):
From 5baf36733401865b6b23320bd52e54a436b54492 Mon Sep 17 00:00:00 2001
From: Patrick Eissler <77415730+PatrickDEissler@users.noreply.github.com>
Date: Tue, 3 Feb 2026 10:18:42 +0100
Subject: [PATCH 12/26] feat: handle cancellation logic
---
.../doctype/absence_plan/absence_plan.js | 21 ++++++++
.../doctype/absence_plan/absence_plan.py | 48 +++++++++++++++++++
2 files changed, 69 insertions(+)
diff --git a/time_capture/time_capture/doctype/absence_plan/absence_plan.js b/time_capture/time_capture/doctype/absence_plan/absence_plan.js
index db5fab9..daf46f7 100644
--- a/time_capture/time_capture/doctype/absence_plan/absence_plan.js
+++ b/time_capture/time_capture/doctype/absence_plan/absence_plan.js
@@ -14,6 +14,27 @@ frappe.ui.form.on("Absence Plan", {
});
},
+ refresh: function (frm) {
+ if (
+ frm.doc.docstatus === 1 &&
+ frm.doc.from_date &&
+ frm.doc.from_date <= frappe.datetime.get_today()
+ ) {
+ frm.add_custom_button(__("Delete Future Dates"), function () {
+ frappe.call({
+ method: "time_capture.time_capture.doctype.absence_plan.absence_plan.delete_future_dates",
+ args: { name: frm.doc.name },
+ callback: function (r) {
+ if (r && r.message) {
+ frappe.msgprint(r.message);
+ }
+ frm.reload_doc();
+ },
+ });
+ });
+ }
+ },
+
employee: function (frm) {
frm.trigger("set_leave_approver");
},
diff --git a/time_capture/time_capture/doctype/absence_plan/absence_plan.py b/time_capture/time_capture/doctype/absence_plan/absence_plan.py
index 5b44f78..17358ad 100644
--- a/time_capture/time_capture/doctype/absence_plan/absence_plan.py
+++ b/time_capture/time_capture/doctype/absence_plan/absence_plan.py
@@ -58,6 +58,12 @@ def on_submit(self):
self.notify_employee()
def before_cancel(self):
+ if getdate(self.from_date) <= getdate():
+ frappe.throw(
+ _(
+ "Absence Plan with dates in the past (or today) cannot be cancelled. Instead you can use the 'Delete Future Dates' button on top of the page. This will delete the future dates from the Absence Plan, but will not effect the past dates."
+ )
+ )
self.status = "Cancelled"
def notify_leave_approver(self):
@@ -196,3 +202,45 @@ def _get_user_lang(user):
if user_lang:
return user_lang
return frappe.local.lang or "en"
+
+
+@frappe.whitelist()
+def delete_future_dates(name: str) -> str:
+ """
+ Delete all dates in the future from the Absence Plan child table and recalculate to_date.
+ Requires Cancel permission. Only for submitted docs with from_date not in the future.
+ """
+ doc = frappe.get_doc("Absence Plan", name)
+ # Validations
+ if doc.docstatus != 1:
+ frappe.throw(_("Only submitted Absence Plans can use this action."))
+ if getdate(doc.from_date) > getdate():
+ frappe.throw(
+ _("From Date is in the future. This action is only for plans that have already started.")
+ )
+ if not doc.has_permission("cancel") and doc.leave_approver != frappe.session.user:
+ frappe.throw(
+ _("Only the Leave Approver and users with the 'Cancel' permission can delete future dates."),
+ title=_("Permission Denied"),
+ )
+
+ # Delete future dates
+ today = getdate()
+ future_rows = [r for r in (doc.dates or []) if getdate(r.date) > today]
+ deleted_count = 0
+ for row in future_rows:
+ frappe.delete_doc("Absence Plan Date", row.name)
+ deleted_count += 1
+
+ if deleted_count:
+ remaining_dates = [getdate(r.date) for r in doc.dates if getdate(r.date) <= today]
+ if remaining_dates:
+ frappe.db.set_value(
+ "Absence Plan",
+ doc.name,
+ {"from_date": min(remaining_dates), "to_date": max(remaining_dates)},
+ )
+ else:
+ frappe.throw(_("All Dates are in the future. Please cancel the Absence Plan instead."))
+
+ return _("Deleted {0} future date(s).").format(deleted_count)
From fa0b9a220c466b5374d2876c80e89b3e69c2c0ce Mon Sep 17 00:00:00 2001
From: Patrick Eissler <77415730+PatrickDEissler@users.noreply.github.com>
Date: Tue, 3 Feb 2026 10:35:25 +0100
Subject: [PATCH 13/26] feat: avoid duplicate dates
---
.../doctype/absence_plan/absence_plan.py | 42 +++++++++++++++++++
1 file changed, 42 insertions(+)
diff --git a/time_capture/time_capture/doctype/absence_plan/absence_plan.py b/time_capture/time_capture/doctype/absence_plan/absence_plan.py
index 17358ad..837a900 100644
--- a/time_capture/time_capture/doctype/absence_plan/absence_plan.py
+++ b/time_capture/time_capture/doctype/absence_plan/absence_plan.py
@@ -49,6 +49,9 @@ def before_validate(self):
self.to_date = max([row.date for row in self.dates])
self.order_dates()
+ def validate(self):
+ self.avoid_duplicates_with_other_absence_plans()
+
def on_update(self):
share_doc_with_approver(self, self.leave_approver)
@@ -145,6 +148,45 @@ def remove_duplicate_dates(self):
def order_dates(self):
self.dates = sorted(self.dates, key=lambda x: x.date)
+ def avoid_duplicates_with_other_absence_plans(self):
+ """
+ Check if the Absence Plan dates overlap with other Absence Plans for the same employee.
+ Dates must be unique per employee; throw if any duplicate is found.
+ """
+ other_plans = frappe.get_all(
+ "Absence Plan",
+ filters={"employee": self.employee, "name": ["!=", self.name or ""]},
+ pluck="name",
+ )
+ if not other_plans:
+ return
+
+ our_dates = {getdate(row.date) for row in self.dates or [] if row.date}
+ overlapping_dates = frappe.db.sql(
+ """
+ SELECT apd.date, apd.parent
+ FROM `tabAbsence Plan Date` apd
+ WHERE apd.parent IN %(plans)s
+ AND apd.date IN %(our_dates)s
+ """,
+ {"plans": other_plans, "our_dates": our_dates},
+ as_dict=True,
+ )
+
+ if overlapping_dates:
+ overlapping_dates_str = "
".join(
+ [
+ f"{frappe.utils.format_date(d.date)} in Absence Plan {frappe.utils.get_link_to_form('Absence Plan', d.parent)}"
+ for d in overlapping_dates
+ ]
+ )
+ frappe.throw(
+ _(
+ "There are overlapping dates with following Absence Plans:
{0}",
+ ).format(overlapping_dates_str),
+ title=_("Overlapping Dates are not allowed"),
+ )
+
@frappe.whitelist()
def bulk_insert_dates(
From 84eb9f7f0918b2d8fec6cf2ac31a533a28fc05d7 Mon Sep 17 00:00:00 2001
From: Patrick Eissler <77415730+PatrickDEissler@users.noreply.github.com>
Date: Tue, 3 Feb 2026 10:45:10 +0100
Subject: [PATCH 14/26] fix: do not consider cancelled absence plans for
overlapping validation
---
time_capture/time_capture/doctype/absence_plan/absence_plan.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/time_capture/time_capture/doctype/absence_plan/absence_plan.py b/time_capture/time_capture/doctype/absence_plan/absence_plan.py
index 837a900..a506754 100644
--- a/time_capture/time_capture/doctype/absence_plan/absence_plan.py
+++ b/time_capture/time_capture/doctype/absence_plan/absence_plan.py
@@ -155,7 +155,7 @@ def avoid_duplicates_with_other_absence_plans(self):
"""
other_plans = frappe.get_all(
"Absence Plan",
- filters={"employee": self.employee, "name": ["!=", self.name or ""]},
+ filters={"employee": self.employee, "name": ["!=", self.name or ""], "docstatus": ["!=", 2]},
pluck="name",
)
if not other_plans:
From 5928d4ec484479ab97f8c573bff1320729f2d114 Mon Sep 17 00:00:00 2001
From: Patrick Eissler <77415730+PatrickDEissler@users.noreply.github.com>
Date: Tue, 3 Feb 2026 10:45:29 +0100
Subject: [PATCH 15/26] chore: remove unused import
---
time_capture/time_capture/doctype/absence_plan/absence_plan.py | 1 -
1 file changed, 1 deletion(-)
diff --git a/time_capture/time_capture/doctype/absence_plan/absence_plan.py b/time_capture/time_capture/doctype/absence_plan/absence_plan.py
index a506754..7c8512d 100644
--- a/time_capture/time_capture/doctype/absence_plan/absence_plan.py
+++ b/time_capture/time_capture/doctype/absence_plan/absence_plan.py
@@ -1,7 +1,6 @@
# Copyright (c) 2025, ALYF GmbH and contributors
# For license information, please see license.txt
-import json
import frappe
from frappe import _
From cf15254009cdc367c7d26b4824dad17727e402ea Mon Sep 17 00:00:00 2001
From: Patrick Eissler <77415730+PatrickDEissler@users.noreply.github.com>
Date: Tue, 3 Feb 2026 10:46:08 +0100
Subject: [PATCH 16/26] docs: fix typo
---
.../time_capture/doctype/absence_plan/absence_plan.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/time_capture/time_capture/doctype/absence_plan/absence_plan.py b/time_capture/time_capture/doctype/absence_plan/absence_plan.py
index 7c8512d..2a737d6 100644
--- a/time_capture/time_capture/doctype/absence_plan/absence_plan.py
+++ b/time_capture/time_capture/doctype/absence_plan/absence_plan.py
@@ -72,7 +72,7 @@ def notify_leave_approver(self):
"""
Notify leave approver about new Absence Plan
"""
- # Preperation
+ # Preparation
if not self.leave_approver:
return
from_user = self.employee_email
@@ -104,7 +104,7 @@ def notify_employee(self):
"""
Notify employee about Absence Plan approval status
"""
- # Preperation
+ # Preparation
to_user = self.employee_email
if not to_user:
return
From 78baa4eca936b2700c06966a78aee5104a7f693b Mon Sep 17 00:00:00 2001
From: Patrick Eissler <77415730+PatrickDEissler@users.noreply.github.com>
Date: Tue, 3 Feb 2026 10:46:45 +0100
Subject: [PATCH 17/26] fix: typo in error message
---
time_capture/time_capture/doctype/absence_plan/absence_plan.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/time_capture/time_capture/doctype/absence_plan/absence_plan.py b/time_capture/time_capture/doctype/absence_plan/absence_plan.py
index 2a737d6..2917b41 100644
--- a/time_capture/time_capture/doctype/absence_plan/absence_plan.py
+++ b/time_capture/time_capture/doctype/absence_plan/absence_plan.py
@@ -63,7 +63,7 @@ def before_cancel(self):
if getdate(self.from_date) <= getdate():
frappe.throw(
_(
- "Absence Plan with dates in the past (or today) cannot be cancelled. Instead you can use the 'Delete Future Dates' button on top of the page. This will delete the future dates from the Absence Plan, but will not effect the past dates."
+ "Absence Plan with dates in the past (or today) cannot be cancelled. Instead you can use the 'Delete Future Dates' button on top of the page. This will delete the future dates from the Absence Plan, but will not affect the past dates."
)
)
self.status = "Cancelled"
From 7f954fd9c717350219a58827c502432847558482 Mon Sep 17 00:00:00 2001
From: Patrick Eissler <77415730+PatrickDEissler@users.noreply.github.com>
Date: Tue, 3 Feb 2026 11:02:40 +0100
Subject: [PATCH 18/26] fix: avoid past dates
---
.../doctype/absence_plan/absence_plan.py | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
diff --git a/time_capture/time_capture/doctype/absence_plan/absence_plan.py b/time_capture/time_capture/doctype/absence_plan/absence_plan.py
index 2917b41..1fa6e19 100644
--- a/time_capture/time_capture/doctype/absence_plan/absence_plan.py
+++ b/time_capture/time_capture/doctype/absence_plan/absence_plan.py
@@ -43,6 +43,7 @@ def after_insert(self):
def before_validate(self):
self.remove_duplicate_dates()
+ self.remove_past_dates()
if self.dates:
self.from_date = min([row.date for row in self.dates])
self.to_date = max([row.date for row in self.dates])
@@ -144,6 +145,24 @@ def remove_duplicate_dates(self):
new_rows.append(row)
self.dates = new_rows
+ def remove_past_dates(self):
+ """Remove dates that are in the past or today."""
+ today = getdate()
+ past_dates = []
+ future_dates = []
+ for row in self.dates:
+ if getdate(row.date) <= today:
+ past_dates.append(row)
+ else:
+ future_dates.append(row)
+ self.dates = future_dates
+ if past_dates:
+ frappe.msgprint(
+ _(
+ "The following dates are in the past (or today) and have been removed from the Absence Plan:
{0}"
+ ).format("
".join([frappe.utils.format_date(row.date) for row in past_dates]))
+ )
+
def order_dates(self):
self.dates = sorted(self.dates, key=lambda x: x.date)
From 7acf11f4414c260e5d98d5492b23229ec031b601 Mon Sep 17 00:00:00 2001
From: Patrick Eissler <77415730+PatrickDEissler@users.noreply.github.com>
Date: Tue, 3 Feb 2026 11:29:02 +0100
Subject: [PATCH 19/26] fix: add user-facing strings
---
.../doctype/absence_plan/absence_plan.js | 51 +++++++++++++++++++
.../doctype/absence_plan/absence_plan.py | 6 +++
2 files changed, 57 insertions(+)
diff --git a/time_capture/time_capture/doctype/absence_plan/absence_plan.js b/time_capture/time_capture/doctype/absence_plan/absence_plan.js
index daf46f7..b2cc8da 100644
--- a/time_capture/time_capture/doctype/absence_plan/absence_plan.js
+++ b/time_capture/time_capture/doctype/absence_plan/absence_plan.js
@@ -39,6 +39,14 @@ frappe.ui.form.on("Absence Plan", {
frm.trigger("set_leave_approver");
},
+ leave_type: function (frm) {
+ frappe.msgprint(
+ __(
+ "Warning: The Leave Ledgers will not be updated for leaves with an Attendance Plan. Therefore, it is not suggested to use Leave Types (such as 'Annual Leave') that have limited Leave Allocation."
+ )
+ );
+ },
+
bulk_insert_btn: function (frm) {
open_bulk_insert_dialog(frm);
},
@@ -61,6 +69,43 @@ frappe.ui.form.on("Absence Plan", {
});
function open_bulk_insert_dialog(frm) {
+ const weekly_off_desc = __(
+ "Weekly Off: adds all dates in the range that fall on the selected weekday. The Holiday List already defines non-working days; adding e.g. every Sunday as weekly off is often redundant."
+ );
+ const weekly_off_example_desc = __(
+ "Typical use case: Every Friday is an unofficial weekly off day."
+ );
+ const weekly_off_exempt_desc = __(
+ "Official weekly offs (e.g. 4 day work week set in employee contract) should be handled via Holiday List."
+ );
+ const timespan_desc = __("Timespan: adds all dates in the range with an optional reason.");
+ const timespan_example_desc = __(
+ "Typical use case: Employee is on Education Leave (University, School, etc.)."
+ );
+ const holiday_list_desc = __(
+ "Holidays from a Holiday List have priority over leaves with an Attendance Plan."
+ );
+ const warning_desc = __(
+ "Warning: The Leave Ledgers will not be updated for leaves with an Attendance Plan. Therefore, it is not suggested to use Leave Types (such as 'Annual Leave') that have limited Leave Allocation."
+ );
+
+ const descriptionHtml =
+ "
" +
+ weekly_off_desc +
+ "
" +
+ weekly_off_example_desc +
+ "
" +
+ weekly_off_exempt_desc +
+ "
" +
+ timespan_desc +
+ "
" +
+ timespan_example_desc +
+ "
" +
+ holiday_list_desc +
+ "
" +
+ warning_desc +
+ "