9 Commits

Author SHA1 Message Date
CPATRD f1ccb7375b Bump Version to 9.6.1 2026-04-15 18:40:48 +02:00
CPAMAP f5ef2aac0f Match ZeitConsens user case-insensitively
The saved tapi-zc-user and the server's MA_USER_NAME are compared
to decide whether to auto-switch the 3CX presence. A mismatch in
capitalization between the two sources silently disabled the sync,
so both are lowercased before comparison.
2026-04-15 17:21:51 +02:00
CPATRD fae65a637c Fixed docker build order for StaticFiles 2026-04-13 14:46:33 +02:00
CPATRD fd5ec49569 TapiDirectoryRepository Tests 2026-04-13 14:19:46 +02:00
CPAMAP 8daa8bf0ff Mark TapiContact properties as required
Silences nullable-reference warnings (CS8618) using the same
pattern already applied to Availability.MA_USER_NAME.
2026-04-13 14:14:14 +02:00
CPAMAP 788ec55bde Filter availability by US_ACTIVE
Restricts the availability listing to projectmanagement.dbo.CP_USER
rows where US_ACTIVE = 1, so deactivated users are not returned.
2026-04-13 14:14:14 +02:00
CPATRD 21683f5e8b Client aktualisiert 2026-04-13 13:23:50 +02:00
CPAMAP 2874ea78c4 Rename firma to atCompany on server and client
Renames the Zeiterfassung-vs-home flag to a clearer English name
(SQL alias AT_COMPANY, model property AT_COMPANY, JSON atCompany)
in both the server query/model and the client AvailabilityInfo
type and people-tile renderer.
2026-04-13 13:18:02 +02:00
CPAMAP d5558b61b2 Parse lastStamp as Date in AvailabilityService
Type AvailabilityInfo.lastStamp as Date and convert from the JSON
string in the service after fetch, so consumers work with real
Date objects.
2026-04-13 13:15:58 +02:00
15 changed files with 68 additions and 35 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "3cx-tapi", "name": "3cx-tapi",
"description": "3CX CP Tapi and Projectmanager integration", "description": "3CX CP Tapi and Projectmanager integration",
"version": "9.6.0", "version": "9.6.1",
"author": { "author": {
"name": "Daniel Triendl", "name": "Daniel Triendl",
"email": "d.triendl@cp-solutions.at" "email": "d.triendl@cp-solutions.at"
+2 -2
View File
@@ -2,6 +2,6 @@ export class AvailabilityInfo {
public user: string; public user: string;
public loggedIn: boolean; public loggedIn: boolean;
public extension: string; public extension: string;
public lastStamp: string; public lastStamp: Date;
public firma: boolean; public atCompany: boolean;
} }
+5 -1
View File
@@ -33,7 +33,11 @@ export class AvailabilityService {
try { try {
var response = await GM_fetch(Config.tapi_server_url + '/availability') var response = await GM_fetch(Config.tapi_server_url + '/availability')
if (response.status === 200) { if (response.status === 200) {
this._availabilities = await response.json() as AvailabilityInfo[] var raw = await response.json() as AvailabilityInfo[]
this._availabilities = raw.map(a => ({
...a,
lastStamp: a.lastStamp ? new Date(a.lastStamp) : null,
}))
this._listeners.forEach(l => l(this._availabilities)) this._listeners.forEach(l => l(this._availabilities))
} }
} catch (error) { } catch (error) {
+2 -2
View File
@@ -67,13 +67,13 @@ export class Availability {
var dotClass = entry.loggedIn ? 'tapi-dot-on' : 'tapi-dot-off' var dotClass = entry.loggedIn ? 'tapi-dot-on' : 'tapi-dot-off'
var time = '' var time = ''
if (entry.lastStamp) { if (entry.lastStamp) {
var d = new Date(entry.lastStamp)
var pad = (n: number) => n.toString().padStart(2, '0') var pad = (n: number) => n.toString().padStart(2, '0')
var d = entry.lastStamp
time = pad(d.getDate()) + '.' + pad(d.getMonth() + 1) + '. ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) time = pad(d.getDate()) + '.' + pad(d.getMonth() + 1) + '. ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
} }
var location = '' var location = ''
if (entry.loggedIn) { if (entry.loggedIn) {
location = entry.firma ? ' · Büro' : ' · Home' location = entry.atCompany ? ' · Büro' : ' · Home'
} }
indicator.innerHTML = '<span class="tapi-dot ' + dotClass + '"></span><small>' + time + location + '</small>' indicator.innerHTML = '<span class="tapi-dot ' + dotClass + '"></span><small>' + time + location + '</small>'
} }
+1 -1
View File
@@ -42,7 +42,7 @@ export class Status {
if (!this._enabled || !this._user) { if (!this._enabled || !this._user) {
return; return;
} }
var entry = avs.find(a => a.user === this._user); var entry = avs.find(a => a.user.toLowerCase() === this._user.toLowerCase());
if (!entry) { if (!entry) {
return; return;
} }
@@ -14,6 +14,8 @@ namespace CPATapi.Client.Models
{ {
/// <summary>Stores additional data not described in the OpenAPI description found when deserializing. Can be used for serialization as well.</summary> /// <summary>Stores additional data not described in the OpenAPI description found when deserializing. Can be used for serialization as well.</summary>
public IDictionary<string, object> AdditionalData { get; set; } public IDictionary<string, object> AdditionalData { get; set; }
/// <summary>The atCompany property</summary>
public bool? AtCompany { get; set; }
/// <summary>The extension property</summary> /// <summary>The extension property</summary>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER #if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable #nullable enable
@@ -22,6 +24,8 @@ namespace CPATapi.Client.Models
#else #else
public string Extension { get; set; } public string Extension { get; set; }
#endif #endif
/// <summary>The lastStamp property</summary>
public DateTimeOffset? LastStamp { get; set; }
/// <summary>The loggedIn property</summary> /// <summary>The loggedIn property</summary>
public bool? LoggedIn { get; set; } public bool? LoggedIn { get; set; }
/// <summary>The user property</summary> /// <summary>The user property</summary>
@@ -57,7 +61,9 @@ namespace CPATapi.Client.Models
{ {
return new Dictionary<string, Action<IParseNode>> return new Dictionary<string, Action<IParseNode>>
{ {
{ "atCompany", n => { AtCompany = n.GetBoolValue(); } },
{ "extension", n => { Extension = n.GetStringValue(); } }, { "extension", n => { Extension = n.GetStringValue(); } },
{ "lastStamp", n => { LastStamp = n.GetDateTimeOffsetValue(); } },
{ "loggedIn", n => { LoggedIn = n.GetBoolValue(); } }, { "loggedIn", n => { LoggedIn = n.GetBoolValue(); } },
{ "user", n => { User = n.GetStringValue(); } }, { "user", n => { User = n.GetStringValue(); } },
}; };
@@ -69,7 +75,9 @@ namespace CPATapi.Client.Models
public virtual void Serialize(ISerializationWriter writer) public virtual void Serialize(ISerializationWriter writer)
{ {
if(ReferenceEquals(writer, null)) throw new ArgumentNullException(nameof(writer)); if(ReferenceEquals(writer, null)) throw new ArgumentNullException(nameof(writer));
writer.WriteBoolValue("atCompany", AtCompany);
writer.WriteStringValue("extension", Extension); writer.WriteStringValue("extension", Extension);
writer.WriteDateTimeOffsetValue("lastStamp", LastStamp);
writer.WriteBoolValue("loggedIn", LoggedIn); writer.WriteBoolValue("loggedIn", LoggedIn);
writer.WriteStringValue("user", User); writer.WriteStringValue("user", User);
writer.WriteAdditionalData(AdditionalData); writer.WriteAdditionalData(AdditionalData);
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"descriptionHash": "3ADB4B190A2637B9EC01981B2508C539F2A582D95310D01FF97D2F2C068B9024CDC66F4D14F486265ED22314E9EEB2EA7CD3BF0F3D1ECC061BA7B9734B520A9D", "descriptionHash": "DEDF4FD053830F062E3DE69918EF38C90DCD76DC47BB854DDA337BB592E2A34DFAAAC5F0C23D40481BD2883833366E7AAC243D825DBCCE2897E4E6A78B890BE9",
"descriptionLocation": "../CPATapi.Server/CPATapi.Server.json", "descriptionLocation": "../CPATapi.Server/CPATapi.Server.json",
"lockFileVersion": "1.0.0", "lockFileVersion": "1.0.0",
"kiotaVersion": "1.30.0", "kiotaVersion": "1.30.0",
@@ -8,7 +8,7 @@
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerfileContext>..\..</DockerfileContext> <DockerfileContext>..\..</DockerfileContext>
<OpenApiDocumentsDirectory>.</OpenApiDocumentsDirectory> <OpenApiDocumentsDirectory>.</OpenApiDocumentsDirectory>
<Version>9.6.0</Version> <Version>9.6.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
+1 -1
View File
@@ -22,6 +22,7 @@ COPY ["server/src/CPATapi.Server/CPATapi.Server.csproj", "server/src/CPATapi.Ser
RUN dotnet restore "server/src/CPATapi.Server/CPATapi.Server.csproj" RUN dotnet restore "server/src/CPATapi.Server/CPATapi.Server.csproj"
COPY . . COPY . .
WORKDIR "/src/server/src/CPATapi.Server" WORKDIR "/src/server/src/CPATapi.Server"
COPY --from=build-userscript ["/src/dist/3CX TAPI.prod.user.js", "./wwwroot/3CX_TAPI.user.js"]
RUN dotnet build "./CPATapi.Server.csproj" -c $BUILD_CONFIGURATION -o /app/build RUN dotnet build "./CPATapi.Server.csproj" -c $BUILD_CONFIGURATION -o /app/build
# This stage is used to publish the service project to be copied to the final stage # This stage is used to publish the service project to be copied to the final stage
@@ -33,5 +34,4 @@ RUN dotnet publish "./CPATapi.Server.csproj" -c $BUILD_CONFIGURATION -o /app/pub
FROM base AS final FROM base AS final
WORKDIR /app WORKDIR /app
COPY --from=publish /app/publish . COPY --from=publish /app/publish .
COPY --from=build-userscript ["/src/dist/3CX TAPI.prod.user.js", "./wwwroot/3CX_TAPI.user.js"]
ENTRYPOINT ["dotnet", "CPATapi.Server.dll"] ENTRYPOINT ["dotnet", "CPATapi.Server.dll"]
@@ -13,6 +13,6 @@ public class Availability
public string? US_EXTENSION { get; set; } public string? US_EXTENSION { get; set; }
[JsonPropertyName("lastStamp")] [JsonPropertyName("lastStamp")]
public DateTime? LAST_STAMP { get; set; } public DateTime? LAST_STAMP { get; set; }
[JsonPropertyName("firma")] [JsonPropertyName("atCompany")]
public bool? FIRMA { get; set; } public bool? AT_COMPANY { get; set; }
} }
@@ -2,9 +2,9 @@ namespace CPATapi.Server.Models;
public class TapiContact public class TapiContact
{ {
public string TD_ID { get; set; } public required string TD_ID { get; set; }
public string TD_NAME { get; set; } public required string TD_NAME { get; set; }
public string TD_NUMBER { get; set; } public required string TD_NUMBER { get; set; }
public string TD_NUMBER_TAPI { get; set; } public required string TD_NUMBER_TAPI { get; set; }
public string TD_MEDIUM { get; set; } public required string TD_MEDIUM { get; set; }
} }
@@ -12,7 +12,7 @@ internal class ZeitConsensRepository(IConfiguration config) : Repository(config)
,bu.LOGGED_IN ,bu.LOGGED_IN
,us.US_EXTENSION ,us.US_EXTENSION
,buLast.LAST_STAMP ,buLast.LAST_STAMP
,buLast.FIRMA ,buLast.AT_COMPANY
FROM dbo.MA_DATEN ma FROM dbo.MA_DATEN ma
INNER JOIN projectmanagement.dbo.CP_USER us ON us.US_LOGINNAME = ma.MA_USER_NAME INNER JOIN projectmanagement.dbo.CP_USER us ON us.US_LOGINNAME = ma.MA_USER_NAME
OUTER APPLY ( OUTER APPLY (
@@ -24,13 +24,14 @@ internal class ZeitConsensRepository(IConfiguration config) : Repository(config)
OUTER APPLY ( OUTER APPLY (
SELECT TOP 1 SELECT TOP 1
bu.BU_BU AS LAST_STAMP bu.BU_BU AS LAST_STAMP
,CASE WHEN bu.BU_TERM = 'Zeiterfassung' THEN 1 ELSE 0 END AS FIRMA ,CASE WHEN bu.BU_TERM = 'Zeiterfassung' THEN 1 ELSE 0 END AS AT_COMPANY
FROM dbo.BU bu FROM dbo.BU bu
WHERE bu.BU_MA_NR = ma.MA_NR WHERE bu.BU_MA_NR = ma.MA_NR
ORDER BY bu.BU_BU DESC ORDER BY bu.BU_BU DESC
) buLast ) buLast
WHERE WHERE
ma.MA_USER_AKTIV = 1 ma.MA_USER_AKTIV = 1
AND us.US_ACTIVE = 1
"""; """;
public async Task<IEnumerable<Availability>> GetUsersAvailabilityAsync(DateTime from, DateTime to) public async Task<IEnumerable<Availability>> GetUsersAvailabilityAsync(DateTime from, DateTime to)
@@ -0,0 +1,18 @@
using Microsoft.Extensions.Configuration;
namespace CPATapi.Server.Tests;
public class RepositoryTestsBase
{
[OneTimeSetUp]
public void Setup()
{
// the type specified here is just so the secrets library can
// find the UserSecretId we added in the csproj file
var builder = new ConfigurationBuilder().AddUserSecrets<RepositoryTestsBase>();
Configuration = builder.Build();
}
protected IConfigurationRoot Configuration { get; private set; }
}
@@ -0,0 +1,15 @@
using CPATapi.Server.Repository;
namespace CPATapi.Server.Tests;
public class TapiDirectoryRepositoryTests : RepositoryTestsBase
{
[Test]
public async Task TestGetAllAsync()
{
var tdRepo = new TapiDirectoryRepository(Configuration);
var contacts = (await tdRepo.GetAllAsync()).ToList();
Assert.That(contacts, Is.Not.Empty);
Assert.That(contacts, Has.Count.GreaterThan(1));
}
}
@@ -1,26 +1,13 @@
using Microsoft.Extensions.Configuration;
using CPATapi.Server.Repository; using CPATapi.Server.Repository;
namespace CPATapi.Server.Tests; namespace CPATapi.Server.Tests;
public class ZeitConsensRepositoryTest public class ZeitConsensRepositoryTest : RepositoryTestsBase
{ {
[OneTimeSetUp]
public void Setup()
{
// the type specified here is just so the secrets library can
// find the UserSecretId we added in the csproj file
var builder = new ConfigurationBuilder().AddUserSecrets<ZeitConsensRepositoryTest>();
_configuration = builder.Build();
}
private IConfigurationRoot _configuration { get; set; }
[Test] [Test]
public async Task TestGetUsersAvailabilityAsync() public async Task TestGetUsersAvailabilityAsync()
{ {
var zcRepo = new ZeitConsensRepository(_configuration); var zcRepo = new ZeitConsensRepository(Configuration);
var availability= (await zcRepo.GetUsersAvailabilityAsync(DateTime.Now.Date, DateTime.Now.AddDays(1).Date)).ToList(); var availability= (await zcRepo.GetUsersAvailabilityAsync(DateTime.Now.Date, DateTime.Now.AddDays(1).Date)).ToList();
Assert.That(availability, Is.Not.Empty); Assert.That(availability, Is.Not.Empty);
Assert.That(availability, Has.Count.GreaterThan(1)); Assert.That(availability, Has.Count.GreaterThan(1));
@@ -29,7 +16,7 @@ public class ZeitConsensRepositoryTest
[Test] [Test]
public async Task TestGetUserAvailabilityAsync() public async Task TestGetUserAvailabilityAsync()
{ {
var zcRepo = new ZeitConsensRepository(_configuration); var zcRepo = new ZeitConsensRepository(Configuration);
var availability = await zcRepo.GetUserAvailabilityAsync("CPATRD", DateTime.Now.Date, DateTime.Now.AddDays(1).Date); var availability = await zcRepo.GetUserAvailabilityAsync("CPATRD", DateTime.Now.Date, DateTime.Now.AddDays(1).Date);
Assert.That(availability, Is.Not.Null); Assert.That(availability, Is.Not.Null);
Assert.That(availability.MA_USER_NAME, Is.EqualTo("CPATRD")); Assert.That(availability.MA_USER_NAME, Is.EqualTo("CPATRD"));