diff --git a/erpnext/projects/doctype/timesheet/timesheet.py b/erpnext/projects/doctype/timesheet/timesheet.py index b37775fc69a00f8427a61af4141d67f3389d006c..a6c9fecd2fce242253c679f390657eac389208d1 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.py +++ b/erpnext/projects/doctype/timesheet/timesheet.py @@ -514,29 +514,39 @@ def get_list_context(context=None): } +def find_ts_for_time_log(values: dict) -> Timesheet | None: + if values.get("parent"): + return frappe.get_doc("Timesheet", values.parent) # type: ignore + + ts_filters = { + "employee": ["=", values.get("employee")], + "docstatus": ["=", 0], + "parent_project": ["=", values.get("project")], + } + + if m := frappe.get_list("Timesheet", filters=ts_filters, fields=["name"], limit=1): + return frappe.get_doc("Timesheet", m[0].name) # type: ignore + + ts_filters["parent_project"] = ["is", "not set"] + if m := frappe.get_list("Timesheet", filters=ts_filters, fields=["name"], limit=1): + return frappe.get_doc("Timesheet", m[0].name) # type: ignore + + @frappe.whitelist() def add_time_log(values: str | dict): - values = frappe.parse_json(values) + values: dict = frappe.parse_json(values) # type: ignore if ( - values.doctype == "Timesheet Details" + values.doctype == "Timesheet Detail" and values.name and frappe.db.exists("Timesheet Detail", values.name) ): return update_time_log(values) # Find a suitable timesheet - ts_filters = { - "employee": ["=", values.employee], - "parent_project": ["is", "not set"], - "docstatus": ["=", 0], - } + ts = find_ts_for_time_log(values) - ts: Timesheet - matches = frappe.get_list("Timesheet", filters=ts_filters, fields=["name"], limit=1) - if matches: - ts = frappe.get_doc("Timesheet", matches[0].name) # type: ignore - else: + if not ts: ts = frappe.new_doc("Timesheet") # type: ignore ts.update( { @@ -557,17 +567,41 @@ def add_time_log(values: str | dict): @frappe.whitelist() def update_time_log(values: str | dict, delete=False): values = frappe.parse_json(values) + try: + tl = frappe.get_doc("Timesheet Detail", values.name) + if tl.parenttype != "Timesheet": + return + except frappe.DoesNotExistError: + if delete: + return # The timelog is already deleted, silently ignore + else: + values.name = None + return add_time_log(values) - tl = frappe.get_doc("Timesheet Detail", values.name) - if tl.parenttype != "Timesheet": - return ts: Timesheet = frappe.get_doc("Timesheet", tl.parent) # type: ignore + if not delete and not ts.is_new(): + # Check that the existing time log is compatible with the new values. + # More precisely, check that the time log is still compatible with its parent. + must_be_moved = False + if values.project and ts.parent_project and (values.project != ts.parent_project): + must_be_moved = True + if values.employee and ts.employee and (values.employee != ts.employee): + must_be_moved = True + + if must_be_moved: + update_time_log(values, delete=True) + values.name = None + return add_time_log(values) + # Update timelog if rows := ts.get("time_logs", {"name": values.name}): row = rows[0] if delete: ts.remove(row) + if not ts.time_logs: + ts.delete() + return None else: row.update(values) ts.save() @@ -590,7 +624,7 @@ def get_time_logs(start, end, filters=None): ["Timesheet Detail", end_date, ">=", start], ] - fields = [ + fields = { "time_logs.parent", "time_logs.name", "time_logs.from_time", @@ -599,7 +633,17 @@ def get_time_logs(start, end, filters=None): "time_logs.activity_type", "time_logs.project", "time_logs.task", - ] + "time_logs.description", + "employee", + } + + meta = frappe.get_meta("Timesheet Detail") + extra_dfs = [] + extra_dfs += meta.get("fields", {"reqd": ("=", 1)}) + extra_dfs += meta.get("fields", {"in_list_view": ("=", 1)}) + for df in extra_dfs: + fields.add("time_logs." + df.fieldname) + events = frappe.get_list("Timesheet", fields=fields, filters=filters) return events diff --git a/erpnext/public/js/projects/PMTimeLogDialog.js b/erpnext/public/js/projects/PMTimeLogDialog.js index 56c193b8722df4265d6f8c1f15110230a705c342..fab05f8acb37ec7b71ac336e9393a91d53edf635 100644 --- a/erpnext/public/js/projects/PMTimeLogDialog.js +++ b/erpnext/public/js/projects/PMTimeLogDialog.js @@ -1,3 +1,8 @@ +import { getCurrentEmployee } from "./PMUtils"; + +// These fields are computed from the dialog values, we don't want to show them in the dialog. +const COMPUTED_FIELDS = ["hours", "from_time", "to_time"]; + export class PMTimeLogDialog { constructor(values, callback) { this._values = Object.assign({}, values || {}); @@ -85,11 +90,17 @@ export class PMTimeLogDialog { if (this._all_fields) { return this._all_fields; } - const isFieldAlreadySpecified = (fieldname) => base_fields.some(df => df.fieldname === fieldname); - const required_fields = this.meta.fields.filter((df) => df.reqd); + const isFieldAlreadySpecified = (fieldname) => { + return base_fields.some(df => df.fieldname === fieldname) || COMPUTED_FIELDS.includes(fieldname); + } + const required_fields = this.meta.fields.filter((df) => df.reqd || df.in_list_view); const base_fields = await this._get_custom_fields(); const missing_fields = required_fields.filter((df) => !isFieldAlreadySpecified(df.fieldname)); - this._all_fields = [...base_fields, ...missing_fields]; + this._all_fields = [...base_fields]; + if (missing_fields.length) { + this._all_fields.push({ fieldtype: "Section Break", label: __("More Information"), hide_border: 1, collapsible: 1 }); + this._all_fields.push(...missing_fields); + } return this._all_fields; } @@ -103,8 +114,8 @@ export class PMTimeLogDialog { ...overrides, }); - const res = await frappe.db.get_value("Employee", { user_id: frappe.session.user }, "name"); - const currentEmployee = res?.message?.name; + const currentEmployee = await getCurrentEmployee(); + const NO_DEPENDS_ON = { depends_on: "", read_only_depends_on: "", mandatory_depends_on: "" }; return [ { @@ -131,8 +142,8 @@ export class PMTimeLogDialog { reqd: 1, }, { fieldtype: "Section Break", label: "Details", hide_border: 1, collapsible: 1 }, - field("project", { depends_on: "", read_only_depends_on: "", mandatory_depends_on: "" }), - field("activity_type", { depends_on: "", read_only_depends_on: "", mandatory_depends_on: "" }), + field("project", NO_DEPENDS_ON), + field("activity_type", NO_DEPENDS_ON), // { // fieldtype: "Link", // fieldname: "user", @@ -160,7 +171,8 @@ export class PMTimeLogDialog { default: currentEmployee, // depends_on: "eval:!doc.employee", }, - { fieldtype: "Section Break" }, + { fieldtype: "Section Break", label: __("Description"), hide_border: 1, collapsible: 1 }, + field("description", NO_DEPENDS_ON), ]; } } \ No newline at end of file diff --git a/erpnext/public/js/projects/PMUtils.js b/erpnext/public/js/projects/PMUtils.js new file mode 100644 index 0000000000000000000000000000000000000000..51c5db6e7d57ae2d0ade2c855cd998355f6316c5 --- /dev/null +++ b/erpnext/public/js/projects/PMUtils.js @@ -0,0 +1,17 @@ +let myOwnEmployee = ""; +let overridenEmployee = ""; + +export async function getCurrentEmployee() { + if (overridenEmployee) { + return overridenEmployee; + } + if (!myOwnEmployee) { + const res = await frappe.db.get_value("Employee", { user_id: frappe.session.user }, "name"); + myOwnEmployee = res?.message?.name; + } + return myOwnEmployee; +} + +export function viewAsAnotherEmployee(employee = "") { + overridenEmployee = employee; +} diff --git a/erpnext/public/js/projects/PMViewCalendar_Assignments.js b/erpnext/public/js/projects/PMViewCalendar_Assignments.js index efb3549ea53f7f733f1d50d7e08744fa7e02ac28..60f8ec836d6fff80102cee0f64d027c9f7f46199 100644 --- a/erpnext/public/js/projects/PMViewCalendar_Assignments.js +++ b/erpnext/public/js/projects/PMViewCalendar_Assignments.js @@ -91,12 +91,12 @@ export class PMViewCalendar_Assignments extends PMViewBaseCalendar { }; } - make() { + async make() { // add links to other calendars this.page.clear_user_actions(); $(this.parent).on("show", this.refresh.bind(this)); - super.make(); + await super.make(); this.footnote_area = frappe.utils.set_footnote( this.footnote_area, diff --git a/erpnext/public/js/projects/PMViewEventSource.js b/erpnext/public/js/projects/PMViewEventSource.js index d3da8da76b8b24de9a72e0770192b414dcf62a61..5ff7a4b0cae250431c2a9acb42c9d39595c2faad 100644 --- a/erpnext/public/js/projects/PMViewEventSource.js +++ b/erpnext/public/js/projects/PMViewEventSource.js @@ -116,7 +116,8 @@ export class PMViewEventSource { } async fetch({ start, end }) { - const events = await frappe.xcall(this.method, await this.getArgs({ start, end })); + const args = await this.getArgs({ start, end }); + const events = await frappe.xcall(this.method, args); return await this.prepareEvents(events); } @@ -124,8 +125,8 @@ export class PMViewEventSource { return { doctype: this.doctype, start: format_ymd(start), - end: format_ymd(frappe.datetime.add_days(end, -1)), - filters: this.getFilters(), + end: format_ymd(end), + filters: await this.getFilters(), field_map: this.field_map, fields: Object.values(this.field_map), }; @@ -477,14 +478,12 @@ export class PMViewEventSource { } const getValue = (k) => String(info.extendedProps?.[k] ?? ""); - const key = this.mainKeys.slice(0, 2).map(getValue).join(""); - const hash1 = stringHash32(key) % 360; - // const startStr = String(String(info.startStr ?? info.start)); - // const hash2 = 5 * (Number(startStr.replace(/[^0-9]/g, "")) % 7) || 0; - - let hue = hash1; - hue = Math.round((hue + 360) % 360); + const key1 = getValue(this.mainKeys[0]); + const hash1 = stringHash32(key1) % 360; + // const key2 = getValue(this.mainKeys[1]); + // const hash2 = stringHash32(key2) % 60; + const hue = Math.round((hash1) % 360); const saturation = 50; const adjust = 20 + .5 * hue - (6.2e-2 * hue)**2 + (1.895e-2 * hue)**3; const lightness = 60 - adjust / 2; diff --git a/erpnext/public/js/projects/ProjectManagementView.js b/erpnext/public/js/projects/ProjectManagementView.js index ddeb58fc190b4469a15cb3a10296f720993b68e8..1e40c9e8b801c0ec2c30a8dfe8495502b4fc967c 100644 --- a/erpnext/public/js/projects/ProjectManagementView.js +++ b/erpnext/public/js/projects/ProjectManagementView.js @@ -7,15 +7,43 @@ import { PMViewCalendar_Timelog } from "./PMViewCalendar_Timelog"; /** @typedef {"schedule" | "assign" | "log"} pm_mode_t */ export class ProjectManagementView extends frappe.views.ListView { - /** @type {pm_mode_t} */ _mode = "assign" + /** @type {pm_mode_t} */ _mode = "log" get mode() { - return this._mode + return this._mode; } set mode(/** @type {pm_mode_t} */ v) { - this._mode = v; + this._mode = v || "log"; this.setActiveCalendar(); } + /** @type {string} */ _date = "" + get date() { + const dateObj = this.calendar?.fullcalendar?.getDate() // in UTC, which might be the previous day + const dateStr = dateObj && frappe.datetime.get_datetime_as_string(dateObj); + return dateStr?.slice(0,10) ?? this._date; + } + set date(v) { + this._date = v; + this.calendar?.fullcalendar?.gotoDate(v); + } + + get_search_params() { + const params = super.get_search_params() + params.append("view", this.mode); + params.append("date", this.date); + return params; + } + + parse_filters_from_route_options() { + if (frappe.route_options) { + const { view, date, ...rest } = frappe.route_options; + frappe.route_options = rest; + this.mode = view ?? ""; + this.date = date ?? ""; + } + return super.parse_filters_from_route_options(); + } + static load_last_view() {} show_skeleton() {} hide_skeleton() {} @@ -35,8 +63,9 @@ export class ProjectManagementView extends frappe.views.ListView { this.pm_setup_sidebar(); } - refresh() { - this.render() + async refresh() { + await this.render(); + this.update_url_with_filters(); } process_document_refreshes() { @@ -71,7 +100,9 @@ export class ProjectManagementView extends frappe.views.ListView { } async rebuildCalendar() { + this.update_url_with_filters(); this.calendar?.destroy?.(); + this.legendContainer.innerHTML = ""; // Always hide filters this.page.hide_form(); @@ -94,10 +125,16 @@ export class ProjectManagementView extends frappe.views.ListView { view: this, parent: this.$result, }); - this.calendar.make(); - - this.legendContainer.innerHTML = ""; this.calendar.setupLegend?.(this.legendContainer); + await this.calendar.make(); + + if (this._date) { + this.calendar.fullcalendar.gotoDate(this._date); + this.calendar.fullcalendar.on("datesSet", (info) => { + this._date = info.startStr.slice(0,10); + this.update_url_with_filters(); + }); + } } pm_setup_sidebar() { diff --git a/erpnext/public/js/projects/eventSources.js b/erpnext/public/js/projects/eventSources.js index d5cbf13e8fc9b0789b2b02a97b243d92f05bc266..d06cb2d2d34cd3feb13235c35c9453f72df4485b 100644 --- a/erpnext/public/js/projects/eventSources.js +++ b/erpnext/public/js/projects/eventSources.js @@ -1,7 +1,7 @@ import { PMTimeLogDialog } from "./PMTimeLogDialog"; +import { getCurrentEmployee } from "./PMUtils"; import { PMViewEventSource } from "./PMViewEventSource"; - const withHoursDiplay = (/** @type {PMViewEventSource} */ C) => { /** @type {PMViewEventSource} */ const WithHoursDiplay = class extends C { @@ -29,7 +29,7 @@ const extractTime = (x) => { export class PMSource_TaskAssigments extends withHoursDiplay(PMViewEventSource) { doctype = "Task Assignment"; - mainKeys = ["task", "project", "employee", "_hours"]; + mainKeys = ["project", "task", "employee", "_hours"]; field_map = { name: "name", start: "start_date", @@ -49,6 +49,20 @@ export class PMSource_TaskAssigmentsLog extends PMSource_TaskAssigments { container.append(this.makeLegendItem(__("Assigned"), { color: "orange", doctype: "Task Assignment" })); } + /** @protected */ async getFilters() { + const filters = await super.getFilters(); + filters.push(["Task Assignment Row", "employee", "=", await getCurrentEmployee()]); + return filters; + } + + async getArgs(params) { + const args = await super.getArgs(params); + const fm = { ...this.field_map, employee: "assigned_to.employee as employee" }; + args.field_map = fm; + args.fields = Object.values(fm); + return args; + } + async prepareEvent(event) { event = await super.prepareEvent(event); event.editable = false; @@ -95,22 +109,13 @@ export class PMSource_TaskAssigmentsLog extends PMSource_TaskAssigments { } export class PMSource_TimeLogs extends withHoursDiplay(PMViewEventSource) { - _currentEmployee = ""; - async getCurrentEmployee() { - if (this._currentEmployee) { - return this._currentEmployee; - } - const res = await frappe.db.get_value("Employee", { user_id: frappe.session.user }, "name"); - this._currentEmployee = res?.message?.name; - } - setupLegend(/** @type {HTMLElement} */ container) { container.append(this.makeLegendItem(__("Saved"), { color: "blue", doctype: "Timesheet Detail" })); } doctype = "Timesheet Detail"; method = "erpnext.projects.doctype.timesheet.timesheet.get_time_logs"; - mainKeys = ["task", "project", "activity_type", "_hours"]; + mainKeys = ["project", "task", "activity_type", "_hours"]; field_map = { name: "name", start: "from_time", @@ -119,12 +124,13 @@ export class PMSource_TimeLogs extends withHoursDiplay(PMViewEventSource) { task: "task", project: "project", activity_type: "activity_type", + description: "description", }; - async getArgs(params) { - const args = await super.getArgs(params); - // args.filters.push(["Timesheet", "employee", "=", this.getCurrentEmployee()]); - return args; + /** @protected */ async getFilters() { + const filters = await super.getFilters(); + filters.push(["Timesheet", "employee", "=", await getCurrentEmployee()]); + return filters; } grabEventStyle() { @@ -137,7 +143,7 @@ export class PMSource_TimeLogs extends withHoursDiplay(PMViewEventSource) { exportEventTime(targetDocument, info) { const time = extractTime(info.event.extendedProps.sourceData.from_time); - targetDocument.from_time = info.startStr + " " + time; + targetDocument.from_time = info.event.startStr + " " + time; targetDocument.to_time = null; targetDocument.hours = info.event.extendedProps.sourceData.hours; return targetDocument; @@ -185,6 +191,23 @@ export class PMSource_TimeLogs extends withHoursDiplay(PMViewEventSource) { if (evt?.extendedProps?.doctype === "Timesheet Detail") { return evt.extendedProps; } + // if (evt?.extendedProps?.doctype === "Timesheet Detail") { + // return new Promise((resolve, reject) => { + // frappe.call({ + // method: "frappe.client.get", + // type: "GET", + // args: { + // doctype: "Timesheet Detail", + // name: evt?.extendedProps?.name, + // parent: "Timesheet", + // }, + // callback: (r) => { + // frappe.model.sync(r.message); + // resolve(r.message); + // }, + // }).fail(reject); + // }) + // } } doGuiCreateDocument(info) { @@ -197,10 +220,13 @@ export class PMSource_TimeLogs extends withHoursDiplay(PMViewEventSource) { async show_dialog_for_event(info) { const event = info?.event; - const [ta, tl] = await Promise.all([ - this.get_task_assignment_for_event(event), - this.get_time_log_for_event(event), - ]); + + let tl = await this.get_time_log_for_event(event); + let ta = null; + if (!tl) { + ta = await this.get_task_assignment_for_event(event); + } + let duration = (tl?.["hours"] * 3600) || (ta?.["duration"]) || 0; duration = Math.round(duration / 60) * 60; const start_time = extractTime(tl?.start_time ?? tl?.start_time);