- Dynamics 365 F&O
- X++
- JavaScript
- Extensible controls
- AX 2012 upgrade
Kalenderwochen in der Datumsauswahl von D365 F&O
AX 2012 zeigte Kalenderwochen in der Datumssuche, D365 F&O nicht. So habe ich ISO-Kalenderwochen mit X++ und JavaScript in jede Datumsauswahl gebracht.

Auf dieser Seite+
In einem Kundenprojekt beim Umstieg von Dynamics AX 2012 auf Dynamics 365 Finance and Operations kam eine Anforderung, die klein klingt: Kalenderwochen im Datumsauswahl-Dialog anzeigen. Einkauf und Logistik planen Lieferungen nach Kalenderwoche. In AX 2012 stand die KW am Anfang jeder Zeile der Datumssuche, und die Teams haben sie den ganzen Tag benutzt. In D365 F&O fehlt sie, und es gibt keine Einstellung, um sie zurückzuholen. Bei Microsoft gibt es dazu eine Idee von 2019, die immer noch offen ist.
Dieser Beitrag zeigt, wie ich ISO-Kalenderwochen in jeden Datumsauswahl-Dialog des Webclients gebracht habe, mit einem erweiterbaren X++-Steuerelement (Extensible Control) und einer kleinen JavaScript-Datei. Der ganze Code steht unten, Sie können ihn also im eigenen Projekt verwenden.
Warum es keine Eigenschaft zum Einschalten gibt
In AX 2012 waren die Kalenderwochen eine einzige Eigenschaft auf dem Formular SysDateLookUp: ShowRowLabels = Yes auf der Tagestabelle. Meine erste Idee war, dasselbe Formular in D365 F&O zu suchen. Es existiert, aber der Webclient verwendet es für die normale Datumsauswahl nicht.
Die Datumsauswahl in D365 F&O ist reiner Client-Code, und es gibt davon zwei:
- Datumsfelder in Formularen und Dialogen verwenden einen jQuery-UI-Datepicker. jQuery UI hat
showWeek,weekHeaderundcalculateWeekschon eingebaut. Der Standardclient setzt sie nur nie. - Datumszellen in Rastern (Grids) verwenden einen React-Datepicker aus der Bibliothek des Grid-Steuerelements. Diese Klasse ist privat, hat keine Wochenoption und verwendet jQuery überhaupt nicht.
Eine Lösung muss also beide abdecken. Das habe ich herausgefunden, indem ich die Client-Skripte unter PackagesLocalDirectory/bin/DesignTimePreview/Scripts gelesen habe, bevor ich Code geschrieben habe. Das hat mir erspart, einen eigenen Datepicker zu bauen.
Der Ansatz
Ein unsichtbares Extensible Control
Ein Event-Handler auf FormRun.onFormRun
Formularfelder: einschalten, was jQuery UI schon kann
showWeek, den übersetzten Spaltenkopf und die Wochenberechnung.Raster: Zeilen markieren, CSS zeichnet
MutationObserver achtet auf einen geöffneten Datepicker im Raster. Er schreibt die Kalenderwoche in ein data--Attribut der ersten Zelle jeder Zeile, und CSS zeichnet sie mit ::before. Die Tabellenstruktur bleibt unverändert, die React-Komponente merkt also nichts.Ändert ein Plattform-Update die Interna des Clients, verschwinden im schlimmsten Fall die Kalenderwochen. Sonst geht nichts kaputt.
Die Lösung besteht aus sieben Objekten:
BALCalendarWeekNumbersBuild
X++-Klasse
Entwurfszeit-Teil des Steuerelements.
BALCalendarWeekNumbersControl
X++-Klasse
Laufzeit-Steuerelement, fügt sich selbst einem Formular hinzu.
BALFormRunEventHandler
X++-Klasse
Abonniert FormRun.onFormRun.
BALCalendarWeekNumbers.htm
Ressource
Vorlage des Steuerelements, lädt Skript, Styles und Label.
BALCalendarWeekNumbers.js
Ressource
Wochenlogik für beide Datepicker.
BALCalendarWeekNumbers.css
Ressource
Layout der Wochenspalte.
BALCalendarWeek
Label-Datei
Spaltenkopf: KW auf Deutsch, Wk auf Englisch.
Der X++-Teil
Die Build-Klasse
Die Build-Klasse muss nur existieren und das Attribut tragen, das sie mit dem Namen des Steuerelements verbindet.
/// <summary>/// The build class for the calendar week numbers control./// </summary>[FormDesignControlAttribute("BALCalendarWeekNumbers")]public class BALCalendarWeekNumbersBuild extends FormBuildControl{}Das Steuerelement
Das Steuerelement verweist auf seine HTML-Ressource. Die statische Methode addToForm fügt es einem Formular einmal hinzu und überspringt Infoboxen (Fact Boxes), damit es nicht mehrfach auf derselben Seite landet.
/// <summary>/// Invisible control that enables calendar week numbers in all date pickers of the web client session./// </summary>[FormControlAttribute("BALCalendarWeekNumbers", "/resources/html/BALCalendarWeekNumbers", classStr(BALCalendarWeekNumbersBuild))]public class BALCalendarWeekNumbersControl extends FormTemplateControl{ private const str ControlName = "__BALCalendarWeekNumbers"; protected void new(FormBuildControl _build, FormRun _formRun) { super(_build, _formRun); this.setTemplateId("BALCalendarWeekNumbers"); this.setResourceBundleName("/resources/html/BALCalendarWeekNumbers"); } /// <summary> /// Adds the control to a form at run time, so that week numbers are enabled in every web client /// session, independent of the start page. /// </summary> /// <param name = "_formRun">The form instance.</param> public static void addToForm(FormRun _formRun) { FormDesign formDesign; if (!_formRun || _formRun.isFactBox()) { return; } formDesign = _formRun.design(); if (formDesign && !formDesign.controlName(ControlName)) { formDesign.addControlEx(classStr(BALCalendarWeekNumbersControl), ControlName); } }}Der Event-Handler
onFormRun ist ein statischer Delegate auf einer Kernel-Klasse, deshalb ist ein Event-Handler hier die einzige Möglichkeit. Hat Ihr Modell schon eine Handler-Klasse für FormRun, legen Sie die Methode dort an statt in einer neuen Klasse.
/// <summary>/// Events that apply to all form instances./// </summary>public class BALFormRunEventHandler{ /// <summary> /// Adds the calendar week numbers control before the form is run. /// </summary> /// <param name = "_formRun">The form instance.</param> [SubscribesTo(classStr(FormRun), staticDelegateStr(FormRun, onFormRun))] public static void FormRun_onFormRun(FormRun _formRun) { BALCalendarWeekNumbersControl::addToForm(_formRun); }}Die Ressourcen
Fügen Sie die drei Dateien in Visual Studio als Ressourcen zum Projekt hinzu (Hinzufügen, Neues Element, Resource). Der Browser lädt sie über den Dateinamen unter /resources/html/, /resources/scripts/ und /resources/styles/.
HTML-Vorlage
Die Vorlage lädt das CSS, das Skript und die Label-Datei in der Sprache des Benutzers. Das div selbst ist ausgeblendet.
<link rel="stylesheet" type="text/css" href="/resources/styles/BALCalendarWeekNumbers.css" /><script src="/resources/scripts/BALCalendarWeekNumbers.js"></script><script src="/resources/labels/BALCalendarWeek.js"></script><div id="BALCalendarWeekNumbers" class="balCalendarWeekNumbers" data-dyn-bind=" visible: $data.Visible, attr: { name: $data.Name }, sizing: $dyn.layout.sizing($data)"></div>Labels
Der Spaltenkopf kommt aus einem Label, das im Browser mit $dyn.label gelesen wird, so wie es die Standard-Steuerelemente auch tun. Verwenden Sie dafür eine eigene, kleine Label-Datei. Die URL /resources/labels/<Datei>.js schickt die ganze Label-Datei an den Browser, und eine große Projekt-Label-Datei hat schnell einige hundert KB.
BALCalendarWeek.en-US.label.txtWeekHeader=Wk ;Header of the week number column in date pickersBALCalendarWeek.de.label.txtWeekHeader=KW ;Header of the week number column in date pickersJavaScript
Das ist der Kern. weekOfRow wird von beiden Datepickern verwendet, damit ein Formularfeld und ein Raster für dieselbe Zeile immer dieselbe Zahl zeigen.
(function () { 'use strict'; var datePickerSelector = '.dyn-datePicker'; var weekTableClass = 'balCalendarWeekTable'; var weekAttribute = 'data-bal-calendar-week'; var weekColorProperty = '--bal-calendar-week-color'; // Label @BALCalendarWeek:WeekHeader, loaded by the HTML resource in the language of the user. var weekHeaderLabel = 'BALCalendarWeek_WeekHeader'; var daysPerWeek = 7; var millisecondsPerDay = 86400000; var weekHeaderText; var initialized = false; var markPending = false; // Returns the ISO 8601 week number of a local date. function isoWeek(date) { var thursday = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); var yearStart; thursday.setUTCDate(thursday.getUTCDate() + 4 - (thursday.getUTCDay() || daysPerWeek)); yearStart = Date.UTC(thursday.getUTCFullYear(), 0, 1); return Math.ceil(((thursday - yearStart) / millisecondsPerDay + 1) / daysPerWeek); } // A calendar row is labelled with the week of its Monday. Both pickers use this function, // so a row shows the same number in a form field and in a grid for every first day of the week. function weekOfRow(firstDateOfRow) { var monday = new Date(firstDateOfRow.getFullYear(), firstDateOfRow.getMonth(), firstDateOfRow.getDate()); monday.setDate(monday.getDate() + (daysPerWeek + 1 - monday.getDay()) % daysPerWeek); return isoWeek(monday); } // Parses a date in the format YYYY-MM-DD as a local date. function parseIsoDate(dateText) { var parts = dateText.split('-'); return new Date(+parts[0], +parts[1] - 1, +parts[2]); } // Week numbers for the jQuery UI date pickers (date fields on forms and dialogs). // jQuery UI calls calculateWeek with the first date of each calendar row. function enableJQueryWeekNumbers() { if (typeof $ === 'undefined' || !$.datepicker) { return; } $.datepicker.setDefaults({ showWeek: true, weekHeader: weekHeaderText, calculateWeek: weekOfRow }); } function setWeekAttribute(cell, value) { if (!value) { if (cell.hasAttribute(weekAttribute)) { cell.removeAttribute(weekAttribute); } } else if (cell.getAttribute(weekAttribute) !== value) { cell.setAttribute(weekAttribute, value); } } function hasVisibleDay(datedCells) { return Array.prototype.some.call(datedCells, function (cell) { return !!cell.textContent; }); } // The week number is drawn inside the first day cell and would inherit its colour, which is white // on the selected day and grey on a day of another month. It gets the colour of the header instead. function setWeekColor(table) { var header = table.querySelector('thead'); var color = header ? window.getComputedStyle(header).color : ''; if (color && table.style.getPropertyValue(weekColorProperty) !== color) { table.style.setProperty(weekColorProperty, color); } } function markRow(row) { var cells = row.children; var datedCells = row.querySelectorAll('[data-date]'); if (!cells.length) { return; } if (!datedCells.length) { if (cells.length === daysPerWeek) { setWeekAttribute(cells[0], weekHeaderText); } return; } // The grid picker appends a row without visible days when the month ends on the last weekday. if (!hasVisibleDay(datedCells)) { setWeekAttribute(cells[0], ''); return; } setWeekAttribute(cells[0], String(weekOfRow(parseIsoDate(datedCells[0].getAttribute('data-date'))))); } // Week numbers for the date pickers of the grid control. The week number is written as an attribute // on the first cell of each row and rendered by CSS, the table structure itself is not changed. function markDatePickers() { var tables = document.querySelectorAll(datePickerSelector + ' table'); Array.prototype.forEach.call(tables, function (table) { if (!table.querySelector('[data-date]')) { return; } if (!table.classList.contains(weekTableClass)) { table.classList.add(weekTableClass); } setWeekColor(table); Array.prototype.forEach.call(table.querySelectorAll('tr'), markRow); }); } // Marks the open grid date pickers at most once per animation frame; returns immediately if none is open. function scheduleMark() { if (markPending) { return; } markPending = true; window.requestAnimationFrame(function () { markPending = false; if (document.querySelector(datePickerSelector)) { markDatePickers(); } }); } // The grid picker changes data-date on every cell when the month changes, // so text changes do not have to be observed. function observeDatePickers() { new MutationObserver(scheduleMark).observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-date'] }); scheduleMark(); } // Runs once per browser page, with the first form that carries the control. function initialize() { if (initialized) { return; } initialized = true; weekHeaderText = $dyn.label(weekHeaderLabel); enableJQueryWeekNumbers(); observeDatePickers(); } $dyn.ui.defaults.BALCalendarWeekNumbers = {}; $dyn.controls.BALCalendarWeekNumbers = function (data, element) { var self = this; $dyn.ui.Control.apply(self, arguments); $dyn.ui.applyDefaults(self, data, $dyn.ui.defaults.BALCalendarWeekNumbers); initialize(); }; $dyn.controls.BALCalendarWeekNumbers.prototype = $dyn.extendPrototype($dyn.ui.Control.prototype, {});})();CSS
.balCalendarWeekNumbers { display: none;}/* jQuery UI date pickers (date fields on forms and dialogs) */.ui-datepicker td.ui-datepicker-week-col { vertical-align: middle; text-align: center; padding-right: 8px; opacity: 0.7;}.ui-datepicker th.ui-datepicker-week-col { text-align: center; padding-right: 8px; font-weight: normal;}/* Grid date pickers: the week number is rendered from the data-bal-calendar-week attribute of the first cell */.balCalendarWeekTable { margin-left: 30px;}.balCalendarWeekTable [data-bal-calendar-week] { position: relative;}.balCalendarWeekTable [data-bal-calendar-week]::before { content: attr(data-bal-calendar-week); color: var(--bal-calendar-week-color); position: absolute; left: -32px; width: 26px; text-align: center; opacity: 0.7; font-weight: normal; pointer-events: none;}Im Browser testen
Erstellen Sie das Modell und öffnen Sie dann ein beliebiges Formular im Webclient mit geöffneten Entwicklertools. Schalten Sie im Reiter Netzwerk Cache deaktivieren ein. Sonst liefert der Browser weiter eine alte Kopie des Skripts aus, und Sie testen die Version von gestern. Nach dem Neuladen sehen Sie, wie die HTML-, CSS- und JS-Ressourcen des Steuerelements geladen werden, und die Wochenspalte in jedem Datepicker, den Sie öffnen.

Das sollten Sie prüfen:
- Ein Datumsfeld in einem Formular, ein Datumsfeld in einem Dialog und eine Datumszelle in einem Raster. Das sind die beiden unterschiedlichen Datepicker.
- Einen Benutzer, dessen Woche am Sonntag beginnt (Benutzeroptionen, Datumsformat), und einen, dessen Woche am Montag beginnt.
- Einen Monat, der am letzten Tag der Woche endet, zum Beispiel Mai 2026.
- Den ausgewählten Tag und Tage des Vor- oder Folgemonats, im hellen und im dunklen Design.
Was am meisten Zeit gekostet hat
Benutzer mit Sonntag als Wochenbeginn. jQuery UI ruft calculateWeek mit dem ersten Datum jeder Zeile auf. Bei einem Benutzer, dessen Woche am Sonntag beginnt, ist das ein Sonntag, und die ISO-Woche eines Sonntags gehört zur Vorwoche. Ein Formularfeld zeigte deshalb 40, während das Raster für dieselbe Zeile 41 zeigte. Die Lösung ist weekOfRow: Beide Datepicker nehmen die Woche des Montags in dieser Zeile.
Eine leere letzte Zeile. Der Datepicker im Raster hängt eine Zeile ohne sichtbare Tage an, wenn ein Monat am letzten Wochentag endet. Ohne Prüfung bekam diese leere Zeile eine Kalenderwoche. hasVisibleDay überspringt sie.
Die unsichtbare Zahl. Die Zahl wird in der ersten Tageszelle gezeichnet und hat deshalb deren Textfarbe übernommen. Am ausgewählten Tag ist das Weiß auf Weiß, an Tagen eines anderen Monats Grau. Das Skript kopiert die Farbe des Spaltenkopfs in die CSS-Variable --bal-calendar-week-color, so sieht die Zahl immer aus wie der Kopf.
Nur ISO-Wochen. Der Code verwendet Wochen nach ISO 8601 (KW 1 enthält den ersten Donnerstag des Jahres), so wie es deutsche und die meisten europäischen Benutzer erwarten. AX 2012 folgte den Windows-Regionaleinstellungen, was bei deutschen Einstellungen dasselbe Ergebnis liefert. Brauchen Ihre Benutzer Wochennummern nach US-Zählung, ändern Sie isoWeek.
Fazit
Drei X++-Klassen, drei Ressourcendateien und ein Label, zusammen etwa 300 Zeilen, und die Benutzer haben ihre Kalenderwochen in jedem Datepicker zurück. Kein Standardformular wurde geändert, und kein Formular braucht eigenen Code.
Wenn Sie das im Standard haben möchten, stimmen Sie für die Idee bei Microsoft ab. Wenn Sie Unterstützung beim Umstieg von AX 2012 auf D365 F&O brauchen oder bei einer Lücke wie dieser, sprechen Sie uns an.