diff --git a/beams/beams/custom_scripts/purchase_invoice/purchase_invoice.js b/beams/beams/custom_scripts/purchase_invoice/purchase_invoice.js index 4d115662f..5cbc67a16 100644 --- a/beams/beams/custom_scripts/purchase_invoice/purchase_invoice.js +++ b/beams/beams/custom_scripts/purchase_invoice/purchase_invoice.js @@ -189,4 +189,25 @@ function fetch_advances_from_mcts(frm) { }, }); } -} \ No newline at end of file +} + +/** + * Fetch cost head from the item doctype. + */ +function set_cost_head(cdt, cdn) { + let row = locals[cdt][cdn]; + if (row.item_code) { + frappe.db.get_value('Item', row.item_code, 'cost_head') + .then(r => { + if (r.message && r.message.cost_head) { + frappe.model.set_value(cdt, cdn, 'cost_head', r.message.cost_head); + } + }); + } +} + +frappe.ui.form.on('Purchase Invoice Item', { + item_code: function(frm, cdt, cdn) { + set_cost_head(cdt, cdn); + } +}); diff --git a/beams/beams/custom_scripts/purchase_invoice/purchase_invoice.py b/beams/beams/custom_scripts/purchase_invoice/purchase_invoice.py index 0f3bd1195..6275d2c71 100644 --- a/beams/beams/custom_scripts/purchase_invoice/purchase_invoice.py +++ b/beams/beams/custom_scripts/purchase_invoice/purchase_invoice.py @@ -33,3 +33,13 @@ def set_from_bureau_flag(doc, method): if "Bureau User" in frappe.get_roles(user): doc.from_bureau = 1 +def set_cost_head_from_item(doc, method=None): + """ + Fetch cost head from item doctype + """ + for row in doc.get("items"): + if not row.item_code: + continue + item_cost_head = frappe.db.get_value("Item", row.item_code, "cost_head") + if item_cost_head: + row.cost_head = item_cost_head diff --git a/beams/beams/custom_scripts/purchase_order/purchase_order.js b/beams/beams/custom_scripts/purchase_order/purchase_order.js index 46e5f6f4b..97ba34c4e 100644 --- a/beams/beams/custom_scripts/purchase_order/purchase_order.js +++ b/beams/beams/custom_scripts/purchase_order/purchase_order.js @@ -48,3 +48,24 @@ function clear_checkbox_exceed(frm){ frm.set_value("is_budget_exceeded", 0); } } + +/** + * Fetch cost head from the item doctype. + */ +function set_cost_head(cdt, cdn) { + let row = locals[cdt][cdn]; + if (row.item_code) { + frappe.db.get_value('Item', row.item_code, 'cost_head') + .then(r => { + if (r.message && r.message.cost_head) { + frappe.model.set_value(cdt, cdn, 'cost_head', r.message.cost_head); + } + }); + } +} + +frappe.ui.form.on('Purchase Order Item', { + item_code: function(frm, cdt, cdn) { + set_cost_head(cdt, cdn); + } +}); diff --git a/beams/beams/custom_scripts/sales_invoice/sales_invoice.js b/beams/beams/custom_scripts/sales_invoice/sales_invoice.js index 6ac985647..2209a5c88 100644 --- a/beams/beams/custom_scripts/sales_invoice/sales_invoice.js +++ b/beams/beams/custom_scripts/sales_invoice/sales_invoice.js @@ -74,3 +74,25 @@ function check_include_in_ibf(frm) { }); } } + +/** + * Fetch cost head from the item doctype. + */ +function set_cost_head(cdt, cdn) { + let row = locals[cdt][cdn]; + if (row.item_code) { + frappe.db.get_value('Item', row.item_code, 'cost_head') + .then(r => { + if (r.message && r.message.cost_head) { + frappe.model.set_value(cdt, cdn, 'cost_head', r.message.cost_head); + } + }); + } +} + +frappe.ui.form.on('Sales Invoice Item', { + item_code: function(frm, cdt, cdn) { + set_cost_head(cdt, cdn); + } +}); + diff --git a/beams/beams/custom_scripts/sales_order/sales_order.js b/beams/beams/custom_scripts/sales_order/sales_order.js index 3e07265a2..bcbf77854 100644 --- a/beams/beams/custom_scripts/sales_order/sales_order.js +++ b/beams/beams/custom_scripts/sales_order/sales_order.js @@ -137,3 +137,24 @@ function check_is_agent_from_customer(frm) { }); } } + +/** + * Fetch cost head from the item doctype. + */ +function set_cost_head(cdt, cdn) { + let row = locals[cdt][cdn]; + if (row.item_code) { + frappe.db.get_value('Item', row.item_code, 'cost_head') + .then(r => { + if (r.message && r.message.cost_head) { + frappe.model.set_value(cdt, cdn, 'cost_head', r.message.cost_head); + } + }); + } +} + +frappe.ui.form.on('Sales Order Item', { + item_code: function(frm, cdt, cdn) { + set_cost_head(cdt, cdn); + } +}); diff --git a/beams/beams/doctype/batta_claim/batta_claim.json b/beams/beams/doctype/batta_claim/batta_claim.json index 3b494027c..060f8aa0d 100644 --- a/beams/beams/doctype/batta_claim/batta_claim.json +++ b/beams/beams/doctype/batta_claim/batta_claim.json @@ -287,11 +287,10 @@ "label": "Is Delhi Bureau" }, { - "depends_on": "eval:doc.workflow_state==\"Pending Approval\"", + "depends_on": "eval:(frappe.user.has_role(\"HOD\") || frappe.user.has_role(\"Admin User\"))", "fieldname": "expense_type", "fieldtype": "Select", "label": "Expense Type", - "mandatory_depends_on": "eval:doc.workflow_state==\"Pending Approval\"", "options": "\nDirect\nIndirect", "permlevel": 1 }, @@ -346,7 +345,7 @@ "link_fieldname": "batta_claim_reference" } ], - "modified": "2026-02-05 14:16:38.084292", + "modified": "2026-04-06 12:13:50.993598", "modified_by": "Administrator", "module": "BEAMS", "name": "Batta Claim", diff --git a/beams/beams/doctype/batta_claim/batta_claim.py b/beams/beams/doctype/batta_claim/batta_claim.py index 7ee0174a2..3b718c430 100644 --- a/beams/beams/doctype/batta_claim/batta_claim.py +++ b/beams/beams/doctype/batta_claim/batta_claim.py @@ -24,7 +24,7 @@ def set_from_bureau_flag(self): self.from_bureau = 1 def on_submit(self): - if self.workflow_state == 'Approved': + if self.workflow_state in ["Approved by CEO", "Approved"] and self.docstatus == 1: if not self.expense_type: frappe.throw( title="Expense Type Required", diff --git a/beams/beams/doctype/beams_accounts_settings/beams_accounts_settings.json b/beams/beams/doctype/beams_accounts_settings/beams_accounts_settings.json index 8c26946f8..9a6f58b86 100644 --- a/beams/beams/doctype/beams_accounts_settings/beams_accounts_settings.json +++ b/beams/beams/doctype/beams_accounts_settings/beams_accounts_settings.json @@ -34,7 +34,8 @@ "column_break_jkfg", "rent_expense_item", "batta_ot_expense_item", - "advance_account" + "advance_account", + "batta_expense_cost_head" ], "fields": [ { @@ -203,12 +204,18 @@ "fieldtype": "Link", "label": "Advance Account", "options": "Account" + }, + { + "fieldname": "batta_expense_cost_head", + "fieldtype": "Link", + "label": "Batta Expense Cost Head", + "options": "Cost Head" } ], "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2026-03-17 14:51:14.096058", + "modified": "2026-03-31 19:44:46.985532", "modified_by": "Administrator", "module": "BEAMS", "name": "Beams Accounts Settings", diff --git a/beams/beams/doctype/bureau_trip_sheet/bureau_trip_sheet.json b/beams/beams/doctype/bureau_trip_sheet/bureau_trip_sheet.json index ff9a812ec..fba8d0a12 100644 --- a/beams/beams/doctype/bureau_trip_sheet/bureau_trip_sheet.json +++ b/beams/beams/doctype/bureau_trip_sheet/bureau_trip_sheet.json @@ -23,6 +23,7 @@ "ending_date_and_time", "check_in_time", "total_hours", + "last_trip", "section_break_tjbr", "purpose", "fuel_details_section", @@ -341,12 +342,18 @@ "fieldname": "check_in_time", "fieldtype": "Datetime", "label": "Check In Time" + }, + { + "default": "0", + "fieldname": "last_trip", + "fieldtype": "Check", + "label": "Last Trip" } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2026-03-26 15:50:44.470313", + "modified": "2026-04-01 10:46:15.616295", "modified_by": "Administrator", "module": "BEAMS", "name": "Bureau Trip Sheet", diff --git a/beams/beams/doctype/bureau_trip_sheet/bureau_trip_sheet.py b/beams/beams/doctype/bureau_trip_sheet/bureau_trip_sheet.py index a0c4505fb..5112db2dc 100644 --- a/beams/beams/doctype/bureau_trip_sheet/bureau_trip_sheet.py +++ b/beams/beams/doctype/bureau_trip_sheet/bureau_trip_sheet.py @@ -53,6 +53,20 @@ def calculate_batta(self): ''' Calculate total trip batta from daily rate × number of days (or food allowance total). ''' + policy = get_batta_policy_values(supplier=self.supplier) + + if policy.get("is_actual_with") or policy.get("is_actual_without") or policy.get("is_actual_food"): + + self.total_food_allowance = ( + flt(self.breakfast) + + flt(self.lunch) + + flt(self.dinner) + ) + + self.batta = self.total_food_allowance + + return + if self.total_food_allowance: self.batta = self.total_food_allowance else: @@ -105,14 +119,25 @@ def calculate_total_daily_batta(self): self.total_daily_batta = flt(self.batta) or 0 def calculate_total_ot_batta(self): - """Total OT batta = (total_hours - ot_working_hours) * ot_batta rate, when supplier and hours are set.""" + """ + Calculate OT batta ONLY when last_trip is checked + """ + + if not self.last_trip: + self.total_ot_batta = 0 + return + total_hours = flt(self.total_hours or 0) ot_rate = flt(self.ot_batta or 0) + if not self.supplier or not total_hours: self.total_ot_batta = 0 return + ot_working_hours = flt(get_ot_working_hours(self.supplier) or 0) + ot_hours = max(0, total_hours - ot_working_hours) + self.total_ot_batta = round(ot_hours * ot_rate, 2) def calculate_daily_batta(self): @@ -126,6 +151,11 @@ def calculate_daily_batta(self): - Else → No Allowance When policy allows actual (editable) daily amounts, user-entered values are preserved on save. ''' + policy = get_batta_policy_values(supplier=self.supplier) + + if policy.get("is_actual_with") or policy.get("is_actual_without") or policy.get("is_actual_food"): + return + # Preserve manually entered daily rates before reset. manual_daily_batta_without_overnight = flt(self.get("daily_batta_without_overnight_stay")) manual_daily_batta_with_overnight = flt(self.get("daily_batta_with_overnight_stay")) diff --git a/beams/beams/doctype/monthly_consolidated_trip_sheet/monthly_consolidated_trip_sheet.py b/beams/beams/doctype/monthly_consolidated_trip_sheet/monthly_consolidated_trip_sheet.py index fb6da9575..5d74f968c 100644 --- a/beams/beams/doctype/monthly_consolidated_trip_sheet/monthly_consolidated_trip_sheet.py +++ b/beams/beams/doctype/monthly_consolidated_trip_sheet/monthly_consolidated_trip_sheet.py @@ -301,6 +301,7 @@ def create_journal_entry(monthly_consolidated_trip_sheet_name): fuel_expense_account = settings.get("fuel_expense_item") ot_account = settings.get("batta_ot_expense_item") fuel_card_account = settings.get("fuel_card_account") + batta_cost_head = settings.get("batta_expense_cost_head") supplier_payable_account = _get_supplier_payable_account(doc.supplier, company) if not supplier_payable_account: @@ -336,18 +337,21 @@ def create_journal_entry(monthly_consolidated_trip_sheet_name): "account": batta_account, "debit_in_account_currency": total_batta_je, "credit_in_account_currency": 0, + "cost_head": batta_cost_head }) if ot_account and total_ot_je: accounts.append({ "account": ot_account, "debit_in_account_currency": total_ot_je, "credit_in_account_currency": 0, + "cost_head": batta_cost_head }) if fuel_expense_account and total_fuel_expense: accounts.append({ "account": fuel_expense_account, "debit_in_account_currency": total_fuel_expense, "credit_in_account_currency": 0, + "cost_head": batta_cost_head }) # Credits — fuel card / fuel log only (advance is already reflected in batta_after / ot_after) if fuel_card_account and total_fuel_log: @@ -355,6 +359,7 @@ def create_journal_entry(monthly_consolidated_trip_sheet_name): "account": fuel_card_account, "debit_in_account_currency": 0, "credit_in_account_currency": total_fuel_log, + "cost_head": batta_cost_head }) # Supplier balancing: credit when company owes supplier, debit when recovering if supplier_payable_account and supplier_amount != 0: @@ -364,6 +369,7 @@ def create_journal_entry(monthly_consolidated_trip_sheet_name): "party": doc.supplier, "debit_in_account_currency": abs(supplier_amount) if supplier_amount < 0 else 0, "credit_in_account_currency": supplier_amount if supplier_amount > 0 else 0, + "cost_head": batta_cost_head }) if not accounts: diff --git a/beams/hooks.py b/beams/hooks.py index b56afcb8b..e071a3d30 100644 --- a/beams/hooks.py +++ b/beams/hooks.py @@ -197,7 +197,10 @@ "Sales Invoice": { "on_update_after_submit":"beams.beams.custom_scripts.sales_invoice.sales_invoice.on_update_after_submit", "autoname": "beams.beams.custom_scripts.sales_invoice.sales_invoice.autoname", - "validate": "beams.beams.custom_scripts.sales_invoice.sales_invoice.validate_sales_invoice_for_barter" + "validate": [ + "beams.beams.custom_scripts.sales_invoice.sales_invoice.validate_sales_invoice_for_barter", + "beams.beams.custom_scripts.purchase_invoice.purchase_invoice.set_cost_head_from_item", + ], }, "Quotation": { "validate": "beams.beams.custom_scripts.quotation.quotation.validate_is_barter", @@ -208,6 +211,7 @@ "before_save": "beams.beams.custom_scripts.purchase_invoice.purchase_invoice.before_save", "before_insert": "beams.beams.custom_scripts.purchase_invoice.purchase_invoice.set_from_bureau_flag", "before_validate": "beams.beams.custom_scripts.purchase_order.purchase_order.set_is_budgeted", + "validate": "beams.beams.custom_scripts.purchase_invoice.purchase_invoice.set_cost_head_from_item", }, "Account": { "after_insert": "beams.beams.custom_scripts.account.account.create_todo_on_creation_for_account" @@ -229,7 +233,10 @@ }, "Purchase Order": { "on_update": "beams.beams.custom_scripts.purchase_order.purchase_order.create_todo_on_finance_verification", - "validate": "beams.beams.custom_scripts.purchase_order.purchase_order.validate_reason_for_rejection", + "validate": [ + "beams.beams.custom_scripts.purchase_order.purchase_order.validate_reason_for_rejection", + "beams.beams.custom_scripts.purchase_invoice.purchase_invoice.set_cost_head_from_item", + ], "before_validate": "beams.beams.custom_scripts.purchase_order.purchase_order.set_is_budgeted", "on_change":"beams.beams.custom_scripts.purchase_order.purchase_order.update_equipment_quantities", }, @@ -243,7 +250,8 @@ "Sales Order": { "autoname": "beams.beams.custom_scripts.sales_order.sales_order.autoname", "before_save": "beams.beams.custom_scripts.sales_order.sales_order.validate_sales_order_amount_with_quotation", - "before_insert": "beams.beams.custom_scripts.sales_order.sales_order.set_region_from_quotation" + "before_insert": "beams.beams.custom_scripts.sales_order.sales_order.set_region_from_quotation", + "validate": "beams.beams.custom_scripts.purchase_invoice.purchase_invoice.set_cost_head_from_item", }, "Contract": { "on_update": "beams.beams.custom_scripts.contract.contract.create_todo_on_contract_verified_by_finance", diff --git a/beams/setup.py b/beams/setup.py index b51e8e843..d9d2d8311 100644 --- a/beams/setup.py +++ b/beams/setup.py @@ -1999,6 +1999,13 @@ def get_item_custom_fields(): "fieldtype": "Check", "label": "Is Bundle Item", "insert_after": "has_variants" + }, + { + "fieldname": "cost_head", + "fieldtype": "Link", + "label": "Cost Head", + "options": "Cost Head", + "insert_after": "item_defaults" } ] } diff --git a/beams/www/roster/index.css b/beams/www/roster/index.css new file mode 100644 index 000000000..53041cc68 --- /dev/null +++ b/beams/www/roster/index.css @@ -0,0 +1,638 @@ +/* ─── Frappe Design Tokens ──────────────────────────────────────── */ +:root { + --primary: #2490ef; + --primary-light: rgba(36, 144, 239, 0.1); + --primary-dark: #1c72be; + --text-color: #1c2126; + --text-muted: #8d99a6; + --text-light: #c4c4c4; + --border-color: #d1d8dd; + --subtle-border: #ebeff2; + --bg-color: #f0f4f7; + --card-bg: #ffffff; + --input-bg: #ffffff; + --navbar-bg: #ffffff; + --red: #e24c4b; + --yellow: #f8a100; + --green: #29cd42; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, .06), 0 1px 3px rgba(0, 0, 0, .10); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, .10), 0 2px 4px -1px rgba(0, 0, 0, .06); + --border-radius: 6px; + --border-radius-md: 8px; + --border-radius-lg: 10px; + --font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --page-head-height: 56px; +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: var(--font); + font-size: 13px; + color: var(--text-color); + background: var(--bg-color); + min-height: 100vh; + -webkit-font-smoothing: antialiased; +} + +/* ─── Page Wrapper ───────────────────────────────────────────────── */ +.page-wrapper { + min-height: calc(100vh - 48px); +} + +/* ─── Page Head ──────────────────────────────────────────────────── */ +.page-head { + background: var(--card-bg); + border-bottom: 1px solid var(--border-color); + padding: 0 24px; + height: var(--page-head-height); + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + position: sticky; + top: 48px; + z-index: 100; +} + +.page-head-left { + display: flex; + flex-direction: column; + gap: 2px; +} + +.page-title { + font-size: 16px; + font-weight: 600; + color: var(--text-color); + line-height: 1; +} + +.page-head-right { + display: flex; + align-items: center; + gap: 8px; +} + +/* ─── Frappe Buttons ─────────────────────────────────────────────── */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + font-family: var(--font); + font-size: 12px; + font-weight: 500; + border-radius: var(--border-radius); + cursor: pointer; + transition: background 0.12s, border-color 0.12s, box-shadow 0.12s; + white-space: nowrap; + text-decoration: none; + border: 1px solid transparent; + line-height: 1; +} + +.btn-sm { + padding: 5px 10px; + font-size: 11px; +} + +.btn-md { + padding: 7px 14px; +} + +.btn-default { + background: var(--card-bg); + border-color: var(--border-color); + color: var(--text-color); +} + +.btn-default:hover { + background: var(--bg-color); + border-color: #b8c2cc; +} + +.btn-default:active { + box-shadow: inset 0 1px 2px rgba(0, 0, 0, .1); +} + +.btn-primary { + background: var(--primary); + border-color: var(--primary); + color: #fff; +} + +.btn-primary:hover { + background: var(--primary-dark); + border-color: var(--primary-dark); +} + +.btn-danger { + background: var(--card-bg); + border-color: var(--border-color); + color: var(--red); +} + +.btn-danger:hover { + background: #fef2f2; + border-color: #fca5a5; +} + +/* ─── View Toggle (Frappe-style segmented) ───────────────────────── */ +.view-toggle { + display: flex; + background: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + padding: 2px; + gap: 2px; +} + +.view-toggle .btn { + padding: 4px 12px; + font-size: 12px; + border: none; + background: transparent; + color: var(--text-muted); + border-radius: 4px; +} + +.view-toggle .btn.active { + background: var(--card-bg); + color: var(--text-color); + box-shadow: var(--shadow-sm); +} + +/* ─── Page Body ──────────────────────────────────────────────────── */ +.page-body { + padding: 20px 24px; +} + +/* ─── Frappe Card ────────────────────────────────────────────────── */ +.frappe-card { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-lg); + box-shadow: var(--shadow-sm); + overflow: hidden; +} + +/* ─── Roster Toolbar ─────────────────────────────────────────────── */ +.roster-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + padding: 12px 16px; + border-bottom: 1px solid var(--subtle-border); +} + +.toolbar-left { + display: flex; + align-items: center; + gap: 8px; +} + +.date-range-label { + font-size: 13px; + font-weight: 500; + color: var(--text-color); +} + +/* ─── Roster Table Wrapper ───────────────────────────────────────── */ +.roster-table-wrap { + overflow-x: auto; + overflow-y: hidden; +} + +/* ─── Roster Table ───────────────────────────────────────────────── */ +.roster-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + table-layout: fixed; +} + +.roster-table th, +.roster-table td { + border-right: 1px solid var(--subtle-border); +} + +.roster-table th:last-child, +.roster-table td:last-child { + border-right: none; +} + +/* thead */ +.roster-table thead tr { + border-bottom: 1px solid var(--border-color); +} + +.th-shift { + padding: 8px 12px; + text-align: left; + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + position: sticky; + left: 0; + background: var(--card-bg); + z-index: 10; +} + +.th-day { + padding: 6px 4px; + text-align: center; + font-weight: 400; + font-size: 11px; + background: var(--card-bg); +} + +.th-day.today { + background: var(--primary-light); +} + +.th-day .day-name { + color: var(--text-muted); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.4px; +} + +.th-day .day-num { + font-size: 14px; + font-weight: 600; + color: var(--text-color); + margin-top: 1px; +} + +.th-day.today .day-name, +.th-day.today .day-num { + color: var(--primary); +} + +.th-day.monthly .day-num { + font-size: 11px; +} + +/* tbody */ +.roster-table tbody tr { + border-bottom: 1px solid var(--subtle-border); + transition: background 0.1s; +} + +.roster-table tbody tr:last-child { + border-bottom: none; +} + +.roster-table tbody tr:hover>td { + background: #f8fafc; +} + +.roster-table tbody tr:hover>.td-shift { + background: #f8fafc; +} + +.td-shift { + padding: 10px 12px; + position: sticky; + left: 0; + background: var(--card-bg); + z-index: 5; + transition: background 0.1s; +} + +.shift-name { + font-weight: 600; + font-size: 12px; + color: var(--text-color); +} + +.shift-time { + font-size: 10px; + color: var(--text-muted); + margin-top: 1px; +} + +.td-cell { + padding: 5px 4px; + vertical-align: top; + background: transparent; +} + +/* Employee chip */ +.emp-chip { + border-radius: 4px; + padding: 2px 6px; + font-size: 10px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 2px; +} + +.emp-chip.monthly { + padding: 2px 3px; + text-align: center; +} + +.more-count { + font-size: 9px; + color: var(--text-muted); + text-align: center; +} + +/* Add button */ +.add-btn { + display: flex; + align-items: center; + justify-content: center; + margin: 1px auto 0; + border: 1px dashed var(--border-color); + background: transparent; + border-radius: var(--border-radius); + cursor: pointer; + font-size: 14px; + font-weight: 400; + color: var(--text-muted); + transition: all 0.12s; + padding: 0; + line-height: 1; +} + +.add-btn:hover { + background: var(--primary-light); + border-color: var(--primary); + color: var(--primary); +} + +.add-btn.weekly { + width: 28px; + height: 22px; +} + +.add-btn.monthly { + width: 20px; + height: 16px; +} + +/* ─── Shift color legends ────────────────────────────────────────── */ +.legend-bar { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 16px; + border-top: 1px solid var(--subtle-border); + flex-wrap: wrap; +} + +.legend-item { + display: flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: var(--text-muted); +} + +.legend-dot { + width: 8px; + height: 8px; + border-radius: 50%; +} + +/* ─── Modal ──────────────────────────────────────────────────────── */ +.modal-backdrop { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, .45); + + /* Higher than Frappe page layers */ + z-index: 10000; + + align-items: center; + justify-content: center; + + /* allow clicks */ + pointer-events: auto; +} + +.modal-backdrop.open { + display: flex; +} + +.modal-dialog { + background: #fff; + width: 340px; + max-height: 540px; + overflow: auto; + + position: relative; + z-index: 10001; + + /* IMPORTANT */ + pointer-events: auto; +} + +.modal-dialog * { + pointer-events: auto !important; +} + +@keyframes modal-in { + from { + opacity: 0; + transform: translateY(-8px) scale(0.98); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 16px 16px 12px; + border-bottom: 1px solid var(--subtle-border); +} + +.modal-title { + font-size: 14px; + font-weight: 600; + color: var(--text-color); +} + +.modal-subtitle { + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; +} + +.modal-close { + border: none; + background: none; + font-size: 16px; + cursor: pointer; + color: var(--text-muted); + padding: 0; + line-height: 1; + margin-left: 8px; + flex-shrink: 0; +} + +.modal-close:hover { + color: var(--text-color); +} + +.modal-body { + padding: 12px 16px; + overflow-y: auto; + flex: 1; +} + +/* Frappe-style search input */ +.frappe-input { + width: 100%; + padding: 6px 10px; + font-family: var(--font); + font-size: 12px; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + background: var(--input-bg); + color: var(--text-color); + outline: none; + transition: border-color 0.12s, box-shadow 0.12s; +} + +.frappe-input:focus { + border-color: var(--primary); + box-shadow: 0 0 0 2px var(--primary-light); +} + +.frappe-input::placeholder { + color: var(--text-muted); +} + +/* Employee list */ +.emp-item { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 8px; + border-radius: var(--border-radius); + cursor: pointer; + transition: background 0.1s; +} + +.emp-item:hover { + background: var(--bg-color); +} + +.emp-name { + font-size: 12px; + color: var(--text-color); +} + +.no-results { + font-size: 12px; + color: var(--text-muted); + text-align: center; + padding: 12px 0; +} + +/* Avatar */ +.avatar { + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 600; + flex-shrink: 0; + background: var(--primary-light); + color: var(--primary); +} + +.avatar.green { + background: rgba(41, 205, 66, 0.12); + color: #1a9c30; +} + +.avatar.sm { + width: 24px; + height: 24px; + font-size: 9px; +} + +/* Assigned section */ +.assigned-section { + display: none; + margin-top: 10px; + border-top: 1px solid var(--subtle-border); + padding-top: 10px; +} + +.section-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); + margin-bottom: 6px; +} + +.assigned-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 5px 8px; + border-radius: var(--border-radius); + margin-bottom: 3px; + background: var(--bg-color); +} + +.assigned-left { + display: flex; + align-items: center; + gap: 8px; +} + +.remove-btn { + border: none; + background: none; + font-size: 13px; + cursor: pointer; + color: var(--text-muted); + padding: 0 2px; + line-height: 1; + font-family: var(--font); + transition: color 0.1s; +} + +.remove-btn:hover { + color: var(--red); +} + +/* ─── Indicator dot ──────────────────────────────────────────────── */ +.indicator { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + margin-right: 4px; + vertical-align: middle; +} \ No newline at end of file diff --git a/beams/www/roster/index.html b/beams/www/roster/index.html new file mode 100644 index 000000000..3999ed238 --- /dev/null +++ b/beams/www/roster/index.html @@ -0,0 +1,100 @@ +{% extends "templates/base.html" %} +{% block content %} + + + + + + + + M1 Roster + + + + + + + + + +
+ + +
+
+
M1 Roster
+
+
+
+ + +
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + + + + +
+
+ + +
+
+
+ + +
+ +
+
+
+ + + + + + +{%- endblock -%} \ No newline at end of file diff --git a/beams/www/roster/index.js b/beams/www/roster/index.js new file mode 100644 index 000000000..8702d0489 --- /dev/null +++ b/beams/www/roster/index.js @@ -0,0 +1,439 @@ +let SHIFTS = []; +let EMPLOYEES = []; +const COLOR_MAP = { + Blue: "#2490ef", + Cyan: "#06b6d4", + Fuchsia: "#d946ef", + Green: "#22c55e", + Lime: "#84cc16", + Orange: "#f97316", + Pink: "#ec4899", + Red: "#ef4444", + Violet: "#8b5cf6", + Yellow: "#eab308" +}; + +const state = { + view: 'weekly', + currentDate: new Date(), + assignments: {}, + activeCell: null, + selectedDepartment: "" +}; + +function get_week_start(d) { + const x = new Date(d), day = x.getDay(); + x.setDate(x.getDate() - day + (day === 0 ? -6 : 1)); + x.setHours(0, 0, 0, 0); return x; +} + +function get_week_days(d) { + const s = get_week_start(d); + return Array.from({ length: 7 }, (_, i) => { const x = new Date(s); x.setDate(s.getDate() + i); return x; }); +} + +function get_month_days(d) { + const y = d.getFullYear(), m = d.getMonth(), n = new Date(y, m + 1, 0).getDate(); + return Array.from({ length: n }, (_, i) => new Date(y, m, i + 1)); +} + +function fmtKey(d) { return d.toISOString().split('T')[0]; } + +function fmt_day_name(d) { return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][d.getDay()]; } + +function is_today(d) { return d.toDateString() === new Date().toDateString(); } + +function cellKey(s, dt) { return `${s}-${dt}`; } + +function initials(n) { + let name = n.split(':')[1] + return name.split(' ').map(x => x[0]).join(''); +} + +function get_days() { return state.view === 'weekly' ? get_week_days(state.currentDate) : get_month_days(state.currentDate); } + +function get_date_label() { + if (state.view === 'weekly') { + const days = get_week_days(state.currentDate), s = days[0], e = days[6]; + const o = { month: 'short', day: 'numeric' }; + return s.getMonth() === e.getMonth() + ? s.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }) + ' · Week of ' + s.getDate() + '-' + e.getDate() + : s.toLocaleDateString('en-US', o) + ' - ' + e.toLocaleDateString('en-US', o) + ', ' + e.getFullYear(); + } + return state.currentDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); +} + +function set_view(v) { + state.view = v; + fetch_shift_assignments().then(() => render()); +} + +function navigate(dir) { + const d = new Date(state.currentDate); + state.view === 'weekly' + ? d.setDate(d.getDate() + dir * 7) + : d.setMonth(d.getMonth() + dir); + + state.currentDate = d; + + fetch_shift_assignments().then(() => render()); +} + +function go_to_today() { + state.currentDate = new Date(); + fetch_shift_assignments().then(() => render()); +} + +function open_modal(shift_id, date_str, shiftLabel, dateLabel, shift_doc_name) { + state.activeCell = { shift_id, date_str, shift_doc_name }; + + document.getElementById('modal-title').textContent = shiftLabel + ' Shift'; + document.getElementById('modal-subtitle').textContent = dateLabel; + document.getElementById('emp-search').value = ''; + // IMPORTANT: restore add mode after using View + document.getElementById('emp-list').style.display = 'block'; + // restore search box too (if hidden later) + document.getElementById('emp-search').style.display = 'block'; + document.getElementById('modal-backdrop').classList.add('open'); + + render_emp_list(); + render_assigned_list(); +} + +function close_modal() { + document.getElementById('modal-backdrop').classList.remove('open'); + state.activeCell = null; +} + +function viewAssignments(shift_id, date_str, shiftLabel, dateLabel, shift_doc_name) { + state.activeCell = { shift_id, date_str, shift_doc_name }; + document.getElementById('modal-title').textContent = shiftLabel + ' Shift Assignments'; + document.getElementById('modal-subtitle').textContent = dateLabel; + // Hide add controls in View mode + document.getElementById('emp-search').style.display = 'none'; + document.getElementById('emp-list').style.display = 'none'; + document.getElementById('modal-backdrop').classList.add('open'); + + render_assigned_list(); +} + +document.getElementById('emp-list').style.display = 'block'; + +function handle_backdrop_click(e) { + if (e.target === document.getElementById('modal-backdrop')) close_modal(); +} + +function filter_employees() { render_emp_list(); } + +function render_emp_list() { + if (!state.activeCell) return; + + const q = document.getElementById('emp-search').value.toLowerCase(); + const { shift_id, date_str } = state.activeCell; + const assigned = state.assignments[cellKey(shift_id, date_str)] || []; + const list = EMPLOYEES.filter(e => e.toLowerCase().includes(q) && !assigned.includes(e)); + const el = document.getElementById('emp-list'); + + if (!list.length) { el.innerHTML = '
No employees found
'; return; } + + el.innerHTML = list.map(emp => ` +
+
${initials(emp)}
+ ${emp} +
`).join(''); +} + +function render_assigned_list() { + if (!state.activeCell) return; + + const { shift_id, date_str, shift_doc_name } = state.activeCell; + const assigned = state.assignments[cellKey(shift_id, date_str)] || []; + const sec = document.getElementById('assigned-section'); + + if (!assigned.length) { sec.style.display = 'none'; return; } + + sec.style.display = 'block'; + document.getElementById('assigned-list').innerHTML = assigned.map(emp => ` +
+
+
${initials(emp)}
+ ${emp} +
+ +
`).join(''); +} + +function assign_emp(emp) { + let emp_id = emp.split(':')[0]; + const { shift_id, date_str, shift_doc_name } = state.activeCell; + const k = cellKey(shift_id, date_str); + if (!state.assignments[k]) state.assignments[k] = []; + if (!state.assignments[k].includes(emp)) state.assignments[k].push(emp); + + create_shift_assignment(shift_doc_name, date_str, emp_id); + renderTable(); + render_emp_list(); + render_assigned_list(); +} +function unassign_emp(emp) { + let emp_id = emp.split(':')[0]; + const { shift_id, date_str, shift_doc_name } = state.activeCell; + const k = cellKey(shift_id, date_str); + if (state.assignments[k]) state.assignments[k] = state.assignments[k].filter(e => e !== emp); + + cancel_shift_assignment(shift_doc_name, date_str, emp_id); + renderTable(); + render_emp_list(); + render_assigned_list(); +} + +function esc(s) { return s.replace(/'/g, "\\'"); } + +/* ── Table Render ─────────────────────────────────────────────── */ +function renderTable() { + const days = get_days(); + const isMon = state.view === 'monthly'; + const scw = isMon ? 90 : 118; + const dcw = isMon ? 42 : 94; + + const cols = ` + + ${days.map(() => ``).join('')} + `; + + const headCells = days.map(d => { + const td = is_today(d); + return ` +
${fmt_day_name(d)}
+
${d.getDate()}
+ `; + }).join(''); + + const thead = ` + Shift + ${headCells} + `; + + const rows = SHIFTS.map(sh => { + const cells = days.map(d => { + const ds = fmtKey(d); + const k = cellKey(sh.id, ds); + const emp = state.assignments[k] || []; + const lim = isMon ? 1 : 2; + const chips = emp.slice(0, lim).map(e => { + const name = isMon ? initials(e) : e.split(' ')[0]; + return `
${name}
`; + }).join(''); + const more = emp.length > lim + ? `
+${emp.length - lim}
` : ''; + const dateLabel = d.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' }); + return ` +${chips}${more} + +
+ +${emp.length > 0 ? ` + +` : ''} + + + +
+ +`; + }).join(''); + return ` + +
+ ${sh.label} +
+
${sh.time}
+ + ${cells} + `; + }).join(''); + + document.getElementById('roster-table').innerHTML = cols + thead + `${rows}`; +} + +function renderLegend() { + document.getElementById('legend-bar').innerHTML = SHIFTS.map(sh => + `
+
+ ${sh.label} +
` + ).join(''); +} + +function updateSummary() { + const days = get_days(); + const total = days.reduce((acc, d) => { + return acc + SHIFTS.reduce((a, sh) => { + return a + (state.assignments[cellKey(sh.id, fmtKey(d))] || []).length; + }, 0); + }, 0); + document.getElementById('summary-label').textContent = + total > 0 ? `${total} assignment${total !== 1 ? 's' : ''} this ${state.view === 'weekly' ? 'week' : 'month'}` : ''; +} + +function render() { + const bw = document.getElementById('btn-weekly'); + const bm = document.getElementById('btn-monthly'); + bw.classList.toggle('active', state.view === 'weekly'); + bm.classList.toggle('active', state.view === 'monthly'); + document.getElementById('date-range-label').textContent = get_date_label(); + document.getElementById('view-indicator').style.background = + state.view === 'weekly' ? '#2490ef' : '#f8a100'; + renderTable(); + renderLegend(); + updateSummary(); +} + +document.addEventListener('keydown', e => { if (e.key === 'Escape') close_modal(); }); + +function hex_to_rbga(hex, opacity = .12) { + hex = hex.replace('#', ''); + let r = parseInt(hex.substring(0, 2), 16); + let g = parseInt(hex.substring(2, 4), 16); + let b = parseInt(hex.substring(4, 6), 16); + return `rgba(${r},${g},${b},${opacity})`; +} + +function loadShifts() { + return frappe.call({ + method: "frappe.client.get_list", + args: { + doctype: "Shift Type", + fields: [ + "name", + "start_time", + "end_time", + "color" + ], + order_by: "name asc", + limit_page_length: 100 + } + }).then(r => { + SHIFTS = (r.message || []).map(shift => { + let shiftColor = COLOR_MAP[shift.color] || "#2490ef"; + return { + name: shift.name, + id: shift.name.toLowerCase().replace(/\s+/g, '_'), + label: shift.name, + time: `${shift.start_time || ''} - ${shift.end_time || ''}`, + color: shiftColor, + bg: hex_to_rbga(shiftColor, 0.12), + text: shiftColor + }; + }); + }); +} + +function load_departments() { + return frappe.call("beams.www.roster.index.get_departments").then(r => { + let depts = r.message || []; + let select = + document.getElementById("department-filter"); + select.innerHTML = + '' + + depts.map(d => + `` + ).join(''); + }); + +} + +function load_employees() { + let dept = document.getElementById("department-filter")?.value; + return frappe.call({ + method: "beams.www.roster.index.get_employees", + args: { + department: dept || '' + } + }).then(r => { + EMPLOYEES = (r.message || []) + .map(e => e.name + ":" + e.employee_name) + .filter(Boolean); + }); +} + +function reload_employees_by_department() { + load_employees().then(() => { + fetch_shift_assignments().then(() => { + render(); + }); + }); +} + +function create_shift_assignment(shift_doc_name, date_str, emp_id) { + return frappe.call({ + method: "beams.www.roster.index.create_shift_assignment", + args: { + shift_type: shift_doc_name, + employee: emp_id, + shift_date: date_str + } + }); +} + +function cancel_shift_assignment(shift_doc_name, date_str, emp_id) { + return frappe.call({ + method: "beams.www.roster.index.cancel_shift_assignment", + args: { + shift_type: shift_doc_name, + employee: emp_id, + shift_date: date_str + } + }); +} + +function fetch_shift_assignments() { + let days = get_days(); + let from_date = fmtKey(days[0]); + let to_date = fmtKey(days[days.length - 1]); + let dept = document.getElementById("department-filter")?.value; + + return frappe.call({ + method: "beams.www.roster.index.get_shift_assignments", + args: { + from_date: from_date, + to_date: to_date, + department: dept || '' + } + }).then(r => { + const assignments = r.message || []; + state.assignments = {}; + assignments.forEach(a => { + let shift_id = a.shift_type.toLowerCase().replace(/\s+/g, '_'); + let date_key = fmtKey(new Date(a.start_date)); + let key = cellKey(shift_id, date_key); + let emp = `${a.employee}:${a.employee_name}`; + if (!state.assignments[key]) { + state.assignments[key] = []; + } + state.assignments[key].push(emp); + }); + }); +} + +$(document).ready(function () { + $(".web-footer").hide(); + Promise.all([ + loadShifts(), + load_departments(), + fetch_shift_assignments() + ]).then(() => { + return load_employees(); + }).then(() => { + render(); + }); +}); \ No newline at end of file diff --git a/beams/www/roster/index.py b/beams/www/roster/index.py new file mode 100644 index 000000000..49ecfd37c --- /dev/null +++ b/beams/www/roster/index.py @@ -0,0 +1,101 @@ +import frappe + +def get_context(context): + ''' + Initializes the context with job applicant details if the applicant ID is valid. + args: + context (dict) + Return : None + ''' + context.no_cache = 1 + +@frappe.whitelist() +def get_shift_assignments(from_date, to_date, department=None): + ''' + Fetches the shift assignments for the current user and returns them as a list of dictionaries. + Return : List of dictionaries containing shift assignment details + ''' + required_fields = ['shift_type', 'employee', 'employee_name', 'start_date'] + filters = { + 'start_date': ['between', [from_date, to_date]], + 'status': 'Active', + 'docstatus': 1 + } + if department: + filters['department'] = department + shift_assignments = frappe.db.get_all('Shift Assignment', filters=filters, fields=required_fields) + return shift_assignments + +@frappe.whitelist() +def create_shift_assignment(employee, shift_type, shift_date): + ''' + Creates a new shift assignment for the specified employee, shift type, and start date. + args: + employee (str): The employee for whom the shift assignment is to be created. + shift_type (str): The type of shift to be assigned. + shift_date (str): The date of the shift assignment in 'YYYY-MM-DD' format. + Return : None + ''' + shift_assignment = frappe.get_doc({ + 'doctype': 'Shift Assignment', + 'employee': employee, + 'shift_type': shift_type, + 'start_date': shift_date, + 'end_date': shift_date + }) + shift_assignment.insert(ignore_permissions=True) + shift_assignment.submit() + return shift_assignment.name + +@frappe.whitelist() +def cancel_shift_assignment(employee, shift_type, shift_date): + ''' + Cancels an existing shift assignment for the specified employee, shift type, and start date. + args: + employee (str): The employee for whom the shift assignment is to be cancelled. + shift_type (str): The type of shift to be cancelled. + shift_date (str): The date of the shift assignment in 'YYYY-MM-DD' format. + Return : None + ''' + shift_assignment = frappe.db.get_value('Shift Assignment', { + 'employee': employee, + 'shift_type': shift_type, + 'start_date': shift_date, + 'status': 'Active', + 'docstatus': 1 + }, 'name') + if shift_assignment: + doc = frappe.get_doc('Shift Assignment', shift_assignment) + doc.cancel() + return shift_assignment + +@frappe.whitelist() +def get_employees(department=None): + ''' + Fetches the list of employees based on the specified department. + args: + department (str, optional): The department to filter employees by. If None, all employees are returned. + Return : List of dictionaries containing employee details + ''' + filters = {'status': 'Active'} + if department: + filters['department'] = department + employees = frappe.db.get_all('Employee', filters=filters, fields=['name', 'employee_name']) + return employees + +@frappe.whitelist() +def get_departments(): + ''' + Fetches the list of departments. + Return : List of dictionaries containing department details + ''' + filters = { + 'is_group': 0 + } + departments = frappe.db.get_list('Department', + filters=filters, + fields=['name', 'department_name'], + order_by='department_name', + limit_page_length=100 + ) + return departments