diff --git a/Layout/default/VoiceCallHistory/Index.php b/Layout/default/VoiceCallHistory/Index.php
new file mode 100644
index 000000000..d83832ade
--- /dev/null
+++ b/Layout/default/VoiceCallHistory/Index.php
@@ -0,0 +1,88 @@
+ self::getUrl("Domain"),
+ "DASHBOARD_URL" => self::getUrl("Dashboard"),
+ "MFAPPNAME" => MFAPPNAME_SLUG,
+ "PAGE_TITLE" => "Domains",
+ "PATH" => [
+ ["text" => MFAPPNAME_SLUG, "href" => self::getUrl("Dashboard")],
+ ["text" => "Voice Calls History", "href" => self::getUrl("VoiceCallHistory")]
+ ],
+ "VOICE_CALL_HISTORY_API_URL" => self::getUrl("VoiceCallHistory/api"),
+];
+
+$additionalJS = ["plugins/vue/vue.js",
+ "plugins/axios/axios.min.js",
+ "plugins/vue/tt-components/tt-page-title.js",
+ "plugins/vue/tt-components/tt-table.js",
+];
+$additionalCSS = [
+ 'plugins/vue/tt-components/css/tt-table.css',
+];
+
+include(realpath(dirname(__FILE__) . "/../../$mfLayoutPackage") . "/header.php"); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Layout/default/menu.php b/Layout/default/menu.php
index d9abd36ee..29c440775 100644
--- a/Layout/default/menu.php
+++ b/Layout/default/menu.php
@@ -125,7 +125,8 @@
diff --git a/application/VoiceCallHistory/VoiceCallHistory.php b/application/VoiceCallHistory/VoiceCallHistory.php
new file mode 100644
index 000000000..e334b4004
--- /dev/null
+++ b/application/VoiceCallHistory/VoiceCallHistory.php
@@ -0,0 +1,9 @@
+loadMe();
+ $this->layout()->set("me", $me);
+ $this->me = $me;
+
+ if (!$this->me->isAdmin()) {
+ $this->redirect("dashboard");
+ }
+
+ $this->kolmisoftMore = new KolmisoftMore($this->VOICE_PORTAL_HOST, $this->VOICE_PORTAL_API_KEY, $this->VOICE_PORTAL_USERNAME);
+
+ }
+
+ protected function indexAction(): void {
+ $this->layout()->setTemplate("VoiceCallHistory/Index");
+ }
+
+ protected function apiAction() {
+ $do = $this->request->do;
+
+ if (!$this->me->isAdmin()) {
+ $this->redirect("dashboard");
+ }
+
+ switch ($do) {
+ case "getCalls":
+ $return = $this->getCalls();
+ break;
+ case "importCallsFromToday":
+ $return = $this->importCallsFromToday();
+ break;
+ default:
+ $return = false;
+ break;
+ }
+
+ if (!$return) {
+ $return = [
+ "status" => "error",
+ "message" => "Invalid request."
+ ];
+ }
+
+ die(json_encode($return));
+ }
+
+ private function importCallsFromToday(): array {
+ $startDate = strtotime(date("Y-m-d 8:00:00"));
+ $endDate = strtotime(date("Y-m-d 9:00:59"));
+
+ $callHistory = $this->kolmisoftMore->getVoiceCallHistory($startDate, $endDate);
+
+ return VoiceCallHistoryModel::importCallsFromKolmisoft($callHistory);
+ }
+
+ private function getCalls(): array {
+ $json = json_decode(file_get_contents('php://input'), true);
+
+ $filters = $json['filters'] ?? [];
+ $page = $json['pagination']['page'] ?? 1;
+ $perPage = $json['pagination']['per_page'] ?? 10;
+
+ $calls = VoiceCallHistoryModel::getVoiceCallHistory($filters, $perPage, $perPage * $page - $perPage);
+ $totalRows = VoiceCallHistoryModel::countVoiceCallHistory($filters);
+
+ return [
+ "rows" => $calls,
+ "pagination" => [
+ "page" => $page,
+ "total_pages" => ceil($totalRows / $perPage),
+ "per_page" => $perPage,
+ "total_rows" => intval($totalRows)
+ ]
+ ];
+
+ }
+
+
+}
\ No newline at end of file
diff --git a/application/VoiceCallHistory/VoiceCallHistoryModel.php b/application/VoiceCallHistory/VoiceCallHistoryModel.php
new file mode 100644
index 000000000..59f6f1d67
--- /dev/null
+++ b/application/VoiceCallHistory/VoiceCallHistoryModel.php
@@ -0,0 +1,146 @@
+ $value) {
+ if (property_exists(get_called_class(), $field)) {
+ $this->$field = $value;
+ }
+ }
+ }
+
+ /**
+ * Generate SQL Filter condition (space separated) for a given column.
+ *
+ * @param string|null $filterValue The filter value to match against.
+ * @param string $columnName The name of the column in the database table.
+ * @return string The SQL condition generated based on the filter value and column name.
+ */
+ public static function generateFilterCondition(?string $filterValue, string $columnName): string {
+ $sql = "";
+ if (!empty($filterValue)) {
+ $filterItems = explode(" ", $filterValue);
+ foreach ($filterItems as $item) {
+ $sql .= " AND `$columnName` LIKE '%" . $item . "%'";
+ }
+ }
+
+ return $sql;
+ }
+
+ //TODO: combine these two functions into one
+ public static function importCallsFromKolmisoft($callHistory): array {
+ {
+
+ $modifiedCallHistory = [];
+ for ($i = 0; $i < count($callHistory); $i++) {
+ $call = $callHistory[$i];
+ $date = date("Y-m-d H:i:s", strtotime($call["calldate2"] . " UTC"));
+ $date = new DateTime($date, new DateTimeZone('UTC'));
+ $date->setTimezone(new DateTimeZone('Europe/Vienna'));
+ $date = $date->format('Y-m-d H:i:s');
+
+ $modifiedCall = [
+ "uid" => $call["uniqueid"],
+ "voice_account" => strpos($call["clid"], "Anonymous") !== false ? 'Anonymous' : preg_replace('/[^0-9]/', '', explode(" ", $call["clid"])[0]),
+ "start" => $date,
+ "source" => strpos($call["clid"], "nymous") !== false ? 'Anonymous' : str_replace("+", "", $call["src"]),
+ "destination" => $call["dst"],
+ "state" => preg_replace('/[^0-9]/', '', explode("(", $call["dispod"])[1]),
+ "billable" => gettype($call["did"]) === "string" ? 0 : 1,
+ "duration" => $call["nice_billsec"],
+ ];
+ $modifiedCallHistory[] = $modifiedCall;
+ }
+
+ $voiceHistoryImport = VoiceCallHistoryModel::importVoiceCallHistory($modifiedCallHistory);
+
+ if ($callHistory === false) {
+ return [
+ "status" => "error",
+ "message" => "Failed to import calls."
+ ];
+ }
+
+ return [
+ "status" => "success",
+ "message" => $voiceHistoryImport["message"]
+ ];
+ }
+ }
+
+ public static function importVoiceCallHistory($callHistory): array {
+ $db = FronkDB::singleton();
+
+ $sql = /** @lang text */ "INSERT INTO `VoiceCallHistory` (`uid`, `voice_account`, `start`, `source`, `destination`, `state`, `billable`, `duration`) VALUES ";
+ $values = [];
+ foreach ($callHistory as $voiceCall) {
+ $uid = $voiceCall['uid'];
+ $valueStr = "(" .
+ ($voiceCall['uid'] === 'Anonymous' ? 'NULL' : "'$uid'") . ", " .
+ ($voiceCall['voice_account'] === 'Anonymous' ? 'NULL' : "'" . $voiceCall['voice_account']. "'" ) . ", '" .
+ $voiceCall['start'] . "', '" .
+ ($voiceCall['source'] === 'Anonymous' ? "NULL" : $voiceCall['source']) . "', '" .
+ ($voiceCall['destination'] === 'Anonymous' ? "NULL" : $voiceCall['destination'] ) . "', " .
+ $voiceCall['state'] . ", " .
+ $voiceCall['billable'] . ", " .
+ $voiceCall['duration'] . ")";
+
+ $values[] = $valueStr;
+ }
+
+ $sql .= implode(", ", $values);
+ $sql .= " ON DUPLICATE KEY UPDATE duration = VALUES(duration)";
+
+ $db->query($sql);
+
+ return [
+ "message" => "Imported " . count($callHistory) . " calls to the history."
+ ];
+ }
+
+ public static function getSqlFilter($filters): string {
+
+ $sql = "";
+ if (isset($filters['start']['from']) && isset($filters['start']['to'])) {
+ $sql = " AND `start` >= FROM_UNIXTIME(" . $filters['start']['from'] . ") AND `start` <= FROM_UNIXTIME(" . $filters['start']['to'] . ")";
+ }
+ $sql .= isset($filters['end']) ? " AND `start` <= '" . $filters['end'] . "'" : "";
+ $sql .= isset($filters['source']) ? self::generateFilterCondition($filters['source'], "source") : "";
+ $sql .= isset($filters['destination']) ? self::generateFilterCondition($filters['destination'], "destination") : "";
+ $sql .= isset($filters['billable']) ? self::generateFilterCondition($filters['billable'], "billable") : "";
+ $sql .= isset($filters['duration']) ? self::generateFilterCondition($filters['duration'], "duration") : "";
+
+ return $sql;
+ }
+
+ public static function getVoiceCallHistory($filters, $limit = null, $offset = 0): array {
+ $db = FronkDB::singleton();
+ $sql = "SELECT * FROM `VoiceCallHistory` WHERE 1 " . self::getSqlFilter($filters);
+ $sql .= $limit === null ? "" : " LIMIT " . $limit . " OFFSET " . $offset;
+
+ $result = $db->query($sql);
+ $rows = [];
+ while ($row = $result->fetch_assoc()) {
+ $rows[] = new VoiceCallHistoryModel($row);
+ }
+
+ return $rows;
+ }
+
+ public static function countVoiceCallHistory($filters) {
+ $db = FronkDB::singleton();
+ $sql = "SELECT COUNT(*) as `total_rows` FROM `VoiceCallHistory` WHERE 1 " . self::getSqlFilter($filters);
+ $result = $db->query($sql);
+ return $result->fetch_assoc()['total_rows'];
+ }
+}
\ No newline at end of file
diff --git a/application/VoiceCallHistoryJob/VoiceCallHistoryJob.php b/application/VoiceCallHistoryJob/VoiceCallHistoryJob.php
new file mode 100644
index 000000000..6dbe12e62
--- /dev/null
+++ b/application/VoiceCallHistoryJob/VoiceCallHistoryJob.php
@@ -0,0 +1,9 @@
+loadMe();
+ $this->layout()->set("me", $me);
+ $this->me = $me;
+
+ if (!$this->me->isAdmin()) {
+ $this->redirect("dashboard");
+ }
+
+ $this->kolmisoftMore = new KolmisoftMore($this->VOICE_PORTAL_HOST, $this->VOICE_PORTAL_API_KEY, $this->VOICE_PORTAL_USERNAME);
+
+ }
+
+ protected function indexAction(): void {
+ $this->layout()->setTemplate("VoiceCallHistoryJob/Index");
+ }
+
+ protected function apiAction() {
+ $do = $this->request->do;
+
+ if (!$this->me->isAdmin()) {
+ $this->redirect("dashboard");
+ }
+
+ switch ($do) {
+ case "runJobs":
+ $return = $this->runJobs();
+ break;
+ case "importCallsFromToday":
+ $return = $this->importCallsFromToday();
+ break;
+ default:
+ $return = false;
+ break;
+ }
+
+ if (!$return) {
+ $return = [
+ "status" => "error",
+ "message" => "Invalid request."
+ ];
+ }
+
+ die(json_encode($return));
+ }
+
+ private function runJobs(): array {
+ VoiceCallHistoryJobModel::createJobsUntilToday();
+
+ $jobs = VoiceCallHistoryJobModel::getJobsNotDone();
+
+ $messages = [
+ "success" => [],
+ "error" => []
+ ];
+ foreach ($jobs as $job) {
+ $startDate = strtotime(date("Y-m-d 00:00:00", strtotime($job->date)));
+ $endDate = strtotime(date("Y-m-d 00:00:00", strtotime($job->date . " +1 day")));
+
+ $callHistory = $this->kolmisoftMore->getVoiceCallHistory($startDate, $endDate);
+
+ $importedCalls = VoiceCallHistoryModel::importCallsFromKolmisoft($callHistory);
+
+ if ($importedCalls) {
+ $messages["success"][$job->date] = $importedCalls["message"];
+ VoiceCallHistoryJobModel::updateJobStatus($job->id, "success");
+ } else {
+ $messages["error"][$job->date] = "Failed to import calls for job $job->id.";
+ VoiceCallHistoryJobModel::updateJobStatus($job->id, "failed");
+ }
+ }
+
+ return $messages;
+ }
+}
\ No newline at end of file
diff --git a/application/VoiceCallHistoryJob/VoiceCallHistoryJobModel.php b/application/VoiceCallHistoryJob/VoiceCallHistoryJobModel.php
new file mode 100644
index 000000000..42d540f57
--- /dev/null
+++ b/application/VoiceCallHistoryJob/VoiceCallHistoryJobModel.php
@@ -0,0 +1,86 @@
+ $value) {
+ if (property_exists(get_called_class(), $field)) {
+ $this->$field = $value;
+ }
+ }
+ }
+
+ /**
+ * Generate SQL Filter condition (space separated) for a given column.
+ *
+ * @param string|null $filterValue The filter value to match against.
+ * @param string $columnName The name of the column in the database table.
+ * @return string The SQL condition generated based on the filter value and column name.
+ */
+ public static function generateFilterCondition(?string $filterValue, string $columnName): string {
+ $sql = "";
+ if (!empty($filterValue)) {
+ $filterItems = explode(" ", $filterValue);
+ foreach ($filterItems as $item) {
+ $sql .= " AND `$columnName` LIKE '%" . $item . "%'";
+ }
+ }
+
+ return $sql;
+ }
+
+ public static function createJobsUntilToday(): array {
+ $db = FronkDB::singleton();
+
+ // $i = first day of the month; $i <= today; $i += 1 day
+ $values = [];
+ for ($i = strtotime(date("Y-m-01")); $i <= strtotime(date("Y-m-d")); $i += 86400) {
+ $values[] = "('" . date("Y-m-d", $i) . "')";
+ }
+
+ $valueStr = implode(", ", $values);
+
+ $db->query("INSERT INTO `VoiceCallHistoryJob` (`date`) VALUES $valueStr ON DUPLICATE KEY UPDATE date=VALUES(date)");
+ $db->query("UPDATE `VoiceCallHistoryJob` SET `status` = 'created', `finished` = NULL WHERE `date` = '" . date("Y-m-d") . "'");
+
+ return [
+ "message" => "Created " . count($values) . " jobs."
+ ];
+ }
+
+ public static function updateJobStatus($id, $status): array {
+ $db = FronkDB::singleton();
+
+ $escapedStatus = $db->escape($status);
+ $escapedId = $db->escape($id);
+
+ $finished = $status == "success" ? ", `finished` = NOW()" : "";
+
+ $db->query("UPDATE `VoiceCallHistoryJob` SET `status` = '$escapedStatus' $finished WHERE `id` = $escapedId");
+
+ return [
+ "message" => "Updated job $id status to $status."
+ ];
+ }
+
+ public static function getJobsNotDone(): array {
+ $db = FronkDB::singleton();
+
+ $query = $db->query("SELECT * FROM `VoiceCallHistoryJob` WHERE `status` = 'created' OR `status` = 'failed' OR `status` = 'pending' ORDER BY `date`");
+
+ $items = [];
+ if($db->num_rows($query)) {
+ while($data = $db->fetch_object($query)) {
+ $items[] = new VoiceCallHistoryJobModel($data);
+ }
+ }
+ return $items;
+ }
+
+}
\ No newline at end of file
diff --git a/db/migrations/20240410150500_add_voice_call_history.php b/db/migrations/20240410150500_add_voice_call_history.php
new file mode 100644
index 000000000..16a2864f7
--- /dev/null
+++ b/db/migrations/20240410150500_add_voice_call_history.php
@@ -0,0 +1,42 @@
+getEnvironment() == "thetool") {
+
+ //VoiceCallHistory Table
+ $voiceCallHistoryTable = $this->table("VoiceCallHistory", ["signed" => true]);
+ $voiceCallHistoryTable->addColumn("id", "integer", ["identity" => true]);
+ $voiceCallHistoryTable->addColumn("uid", "string", ["null" => false, "limit" => 255]);
+ $voiceCallHistoryTable->addColumn("voice_account", "string", ["null" => false, "limit" => 255]);
+ $voiceCallHistoryTable->addColumn("start", "datetime", ["null" => false]);
+ $voiceCallHistoryTable->addColumn("source", "string", ["null" => true, "limit" => 255]);
+ $voiceCallHistoryTable->addColumn("destination", "string", ["null" => true, "limit" => 255]);
+ $voiceCallHistoryTable->addColumn("state", "integer", ["null" => true]);
+ $voiceCallHistoryTable->addColumn("billable", "integer", ["null" => false, "limit" => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY]);
+ $voiceCallHistoryTable->addColumn("duration", "integer", ["null" => false]);
+
+ //VoiceCallHistory Table Indexes
+ $voiceCallHistoryTable->addIndex(["billable"]);
+ $voiceCallHistoryTable->addIndex(["voice_account"]);
+
+ //VoiceCallHistoryJob Table
+ $voiceCallHistoryJobTable = $this->table("VoiceCallHistoryJob", ["signed" => true]);
+ $voiceCallHistoryJobTable->addColumn("id", "integer", ["identity" => true]);
+ $voiceCallHistoryJobTable->addColumn("date", "date", ["null" => false]);
+ $voiceCallHistoryJobTable->addColumn("status", "enum", ["values" => ["created", "pending", "running", "success", "failed"], "default" => "created", "null" => false]);
+ $voiceCallHistoryJobTable->addColumn("finished", "date", ["null" => true]);
+ $voiceCallHistoryJobTable->addIndex(["date"], ["unique" => true]);
+ }
+ }
+
+ public function down(): void {
+ if ($this->getEnvironment() == "thetool") {
+ $this->table("VoiceCallHistory")->drop()->save();
+ $this->table("VoiceCallHistoryJob")->drop()->save();
+ }
+ }
+}
diff --git a/public/plugins/vue/tt-components/tt-table.js b/public/plugins/vue/tt-components/tt-table.js
index b0f92ab53..3f44c7ecf 100644
--- a/public/plugins/vue/tt-components/tt-table.js
+++ b/public/plugins/vue/tt-components/tt-table.js
@@ -17,6 +17,84 @@
* @property {string} class - The CSS class(es) applied to the column.
*/
+//TODO: export this to its own file
+Vue.component('tt-date-range', {
+ template: `
+
+ `, props: ['value'],
+ data() {
+ return {
+ inputValue: '',
+ isInitialized: false,
+ locale: {
+ "format": "DD.MM.YYYY HH:mm",
+ "separator": " - ",
+ "applyLabel": "Übernehmen",
+ "cancelLabel": "Abbrechen",
+ "fromLabel": "Von",
+ "toLabel": "Bis",
+ "customRangeLabel": "Benutzerdefiniert",
+ "weekLabel": "W",
+ "daysOfWeek": [
+ "So",
+ "Mo",
+ "Di",
+ "Mi",
+ "Do",
+ "Fr",
+ "Sa"
+ ],
+ "monthNames": [
+ "Januar",
+ "Februar",
+ "März",
+ "April",
+ "Mai",
+ "Juni",
+ "Juli",
+ "August",
+ "September",
+ "Oktober",
+ "November",
+ "Dezember"
+ ],
+ "firstDay": 1
+ },
+ }
+ },
+ methods: {
+ initialize() {
+ if (!this.isInitialized) {
+ this.isInitialized = true;
+ $(this.$refs.input).daterangepicker({
+ autoUpdateInput: true,
+ timePicker: true,
+ timePicker24Hour: true,
+ locale: this.locale,
+ });
+
+ this.$refs.input.click();
+
+ const _this = this;
+ $(this.$refs.input).on('apply.daterangepicker', function(ev, picker) {
+ console.log('now emitting chang', picker.startDate.unix(), picker.endDate.unix());
+ _this.$emit('change', {
+ target: {
+ value: {
+ from: picker.startDate.unix() + 7200,
+ to: picker.endDate.unix() + 7200
+ }
+ }
+ });
+ });
+ }
+ }
+ },
+ beforeDestroy() {
+ $(this.$refs.input).off('apply.daterangepicker');
+ },
+})
+
Vue.component('tt-table', {
template: `
@@ -65,7 +143,7 @@ Vue.component('tt-table', {