19 Commits

Author SHA1 Message Date
MarcWieland f47b4d86ba fix: update migration seeding condition to check for PrimaryColor instead of TenantColor 2026-06-26 23:50:03 +02:00
MarcWieland e2e57143b8 Commit 2026-06-26 23:22:51 +02:00
MarcWieland 62e58fc9ad QoL 2026-06-26 23:17:08 +02:00
MarcWieland 4a69095f30 PDFs erzeugen 2026-06-26 22:41:23 +02:00
MarcWieland 42a97a36b4 Layouting fixes 2026-06-26 22:24:45 +02:00
MarcWieland 0c3eb3a323 Loading angepasst 2026-06-26 21:35:23 +02:00
Wieland, Marc 6ff8743dad updates 2026-06-26 15:34:34 +02:00
Wieland, Marc ab966c66d0 Docker update 2026-06-26 15:29:05 +02:00
Wieland, Marc 56fa73656f Update 2026-06-26 15:22:17 +02:00
Wieland, Marc 782be5dd36 improved logging 2026-06-26 15:13:21 +02:00
Wieland, Marc f291d9db7d Commit 2026-06-26 15:05:15 +02:00
Wieland, Marc 52842072e5 Loggin 2026-06-26 14:56:19 +02:00
Wieland, Marc 7d9eac498a Updates 2026-06-26 14:49:30 +02:00
mrcwlnd f4512a807c Merge pull request 'docker' (#17) from testbranch into main
Reviewed-on: #17
2026-06-25 19:26:07 +00:00
MarcWieland a89779a00c docker 2026-06-25 21:25:08 +02:00
mrcwlnd 711ea2a00b Merge pull request 'dockercompose angepasst' (#16) from testbranch into main
Reviewed-on: #16
2026-06-25 19:12:44 +00:00
MarcWieland febc875834 dockercompose angepasst 2026-06-25 21:12:26 +02:00
mrcwlnd c27b1088ee Merge pull request 'sql updates' (#15) from testbranch into main
Reviewed-on: #15
2026-06-25 19:08:29 +00:00
MarcWieland 2305e4b728 sql updates 2026-06-25 21:08:13 +02:00
40 changed files with 5555 additions and 259 deletions
+795
View File
@@ -0,0 +1,795 @@
bash-5.2# ls
timetracker-20260626.log
bash-5.2# cat timetracker-20260626.log
2026-06-26 13:31:25.632 +00:00 [INF] File logging active. Writing logs to /app/logs
2026-06-26 13:31:26.019 +00:00 [WRN] Entity 'WorkDay' has a global query filter defined and is the required end of a relationship with the entity 'BreakEntry'. This may lead to unexpected results when the required entity is filtered out. Either configure the navigation as optional, or define matching query filters for both entities in the navigation. See https://go.microsoft.com/fwlink/?linkid=2131316 for more information.
2026-06-26 13:31:26.249 +00:00 [INF] Executed DbCommand (3ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT "MigrationId", "ProductVersion"
FROM "__EFMigrationsHistory"
ORDER BY "MigrationId";
2026-06-26 13:31:26.270 +00:00 [INF] Executed DbCommand (2ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT 1
2026-06-26 13:31:26.341 +00:00 [INF] Executed DbCommand (1ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
"MigrationId" character varying(150) NOT NULL,
"ProductVersion" character varying(32) NOT NULL,
CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId")
);
2026-06-26 13:31:26.351 +00:00 [INF] Executed DbCommand (0ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
LOCK TABLE "__EFMigrationsHistory" IN ACCESS EXCLUSIVE MODE
2026-06-26 13:31:26.352 +00:00 [INF] Executed DbCommand (0ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT "MigrationId", "ProductVersion"
FROM "__EFMigrationsHistory"
ORDER BY "MigrationId";
2026-06-26 13:31:26.355 +00:00 [INF] No migrations were applied. The database is already up to date.
2026-06-26 13:31:26.548 +00:00 [INF] Executed DbCommand (1ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT u."Id", u."IsTenantAdmin", u."PasswordHash", u."PasswordSalt", u."TenantId", u."Username"
FROM "Users" AS u
WHERE u."TenantId" IS NULL AND u."Username" = 'marc'
LIMIT 1
2026-06-26 13:31:26.705 +00:00 [ERR] Failed executing DbCommand (6ms) [Parameters=[@p0='?' (DbType = Boolean), @p1='?', @p2='?', @p3='?' (DbType = Int32), @p4='?'], CommandType='"Text"', CommandTimeout='30']
INSERT INTO "Users" ("IsTenantAdmin", "PasswordHash", "PasswordSalt", "TenantId", "Username")
VALUES (@p0, @p1, @p2, @p3, @p4)
RETURNING "Id";
2026-06-26 13:31:26.719 +00:00 [ERR] An exception occurred in the database while saving changes for context type 'timetracker.Data.TimetrackerDbContext'.
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
---> Npgsql.PostgresException (0x80004005): 42804: column "IsTenantAdmin" is of type integer but expression is of type boolean
POSITION: 103
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
Exception data:
Severity: ERROR
SqlState: 42804
MessageText: column "IsTenantAdmin" is of type integer but expression is of type boolean
Hint: You will need to rewrite or cast the expression.
Position: 103
File: parse_target.c
Line: 586
Routine: transformAssignedExpr
--- End of inner exception stack trace ---
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalDatabase.SaveChangesAsync(IList`1 entries, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList`1 entriesToSave, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(StateManager stateManager, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
at Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.NpgsqlExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
---> Npgsql.PostgresException (0x80004005): 42804: column "IsTenantAdmin" is of type integer but expression is of type boolean
POSITION: 103
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
Exception data:
Severity: ERROR
SqlState: 42804
MessageText: column "IsTenantAdmin" is of type integer but expression is of type boolean
Hint: You will need to rewrite or cast the expression.
Position: 103
File: parse_target.c
Line: 586
Routine: transformAssignedExpr
--- End of inner exception stack trace ---
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalDatabase.SaveChangesAsync(IList`1 entries, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList`1 entriesToSave, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(StateManager stateManager, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
at Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.NpgsqlExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
2026-06-26 13:31:28.470 +00:00 [INF] File logging active. Writing logs to /app/logs
2026-06-26 13:31:28.848 +00:00 [WRN] Entity 'WorkDay' has a global query filter defined and is the required end of a relationship with the entity 'BreakEntry'. This may lead to unexpected results when the required entity is filtered out. Either configure the navigation as optional, or define matching query filters for both entities in the navigation. See https://go.microsoft.com/fwlink/?linkid=2131316 for more information.
2026-06-26 13:31:29.078 +00:00 [INF] Executed DbCommand (3ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT "MigrationId", "ProductVersion"
FROM "__EFMigrationsHistory"
ORDER BY "MigrationId";
2026-06-26 13:31:29.100 +00:00 [INF] Executed DbCommand (2ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT 1
2026-06-26 13:31:29.170 +00:00 [INF] Executed DbCommand (1ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
"MigrationId" character varying(150) NOT NULL,
"ProductVersion" character varying(32) NOT NULL,
CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId")
);
2026-06-26 13:31:29.180 +00:00 [INF] Executed DbCommand (0ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
LOCK TABLE "__EFMigrationsHistory" IN ACCESS EXCLUSIVE MODE
2026-06-26 13:31:29.182 +00:00 [INF] Executed DbCommand (0ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT "MigrationId", "ProductVersion"
FROM "__EFMigrationsHistory"
ORDER BY "MigrationId";
2026-06-26 13:31:29.185 +00:00 [INF] No migrations were applied. The database is already up to date.
2026-06-26 13:31:29.376 +00:00 [INF] Executed DbCommand (1ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT u."Id", u."IsTenantAdmin", u."PasswordHash", u."PasswordSalt", u."TenantId", u."Username"
FROM "Users" AS u
WHERE u."TenantId" IS NULL AND u."Username" = 'marc'
LIMIT 1
2026-06-26 13:31:29.537 +00:00 [ERR] Failed executing DbCommand (6ms) [Parameters=[@p0='?' (DbType = Boolean), @p1='?', @p2='?', @p3='?' (DbType = Int32), @p4='?'], CommandType='"Text"', CommandTimeout='30']
INSERT INTO "Users" ("IsTenantAdmin", "PasswordHash", "PasswordSalt", "TenantId", "Username")
VALUES (@p0, @p1, @p2, @p3, @p4)
RETURNING "Id";
2026-06-26 13:31:29.548 +00:00 [ERR] An exception occurred in the database while saving changes for context type 'timetracker.Data.TimetrackerDbContext'.
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
---> Npgsql.PostgresException (0x80004005): 42804: column "IsTenantAdmin" is of type integer but expression is of type boolean
POSITION: 103
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
Exception data:
Severity: ERROR
SqlState: 42804
MessageText: column "IsTenantAdmin" is of type integer but expression is of type boolean
Hint: You will need to rewrite or cast the expression.
Position: 103
File: parse_target.c
Line: 586
Routine: transformAssignedExpr
--- End of inner exception stack trace ---
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalDatabase.SaveChangesAsync(IList`1 entries, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList`1 entriesToSave, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(StateManager stateManager, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
at Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.NpgsqlExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
---> Npgsql.PostgresException (0x80004005): 42804: column "IsTenantAdmin" is of type integer but expression is of type boolean
POSITION: 103
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
Exception data:
Severity: ERROR
SqlState: 42804
MessageText: column "IsTenantAdmin" is of type integer but expression is of type boolean
Hint: You will need to rewrite or cast the expression.
Position: 103
File: parse_target.c
Line: 586
Routine: transformAssignedExpr
--- End of inner exception stack trace ---
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalDatabase.SaveChangesAsync(IList`1 entries, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList`1 entriesToSave, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(StateManager stateManager, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
at Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.NpgsqlExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
2026-06-26 13:31:31.379 +00:00 [INF] File logging active. Writing logs to /app/logs
2026-06-26 13:31:31.760 +00:00 [WRN] Entity 'WorkDay' has a global query filter defined and is the required end of a relationship with the entity 'BreakEntry'. This may lead to unexpected results when the required entity is filtered out. Either configure the navigation as optional, or define matching query filters for both entities in the navigation. See https://go.microsoft.com/fwlink/?linkid=2131316 for more information.
2026-06-26 13:31:31.990 +00:00 [INF] Executed DbCommand (3ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT "MigrationId", "ProductVersion"
FROM "__EFMigrationsHistory"
ORDER BY "MigrationId";
2026-06-26 13:31:32.011 +00:00 [INF] Executed DbCommand (2ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT 1
2026-06-26 13:31:32.082 +00:00 [INF] Executed DbCommand (1ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
"MigrationId" character varying(150) NOT NULL,
"ProductVersion" character varying(32) NOT NULL,
CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId")
);
2026-06-26 13:31:32.091 +00:00 [INF] Executed DbCommand (0ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
LOCK TABLE "__EFMigrationsHistory" IN ACCESS EXCLUSIVE MODE
2026-06-26 13:31:32.094 +00:00 [INF] Executed DbCommand (0ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT "MigrationId", "ProductVersion"
FROM "__EFMigrationsHistory"
ORDER BY "MigrationId";
2026-06-26 13:31:32.097 +00:00 [INF] No migrations were applied. The database is already up to date.
2026-06-26 13:31:32.290 +00:00 [INF] Executed DbCommand (1ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT u."Id", u."IsTenantAdmin", u."PasswordHash", u."PasswordSalt", u."TenantId", u."Username"
FROM "Users" AS u
WHERE u."TenantId" IS NULL AND u."Username" = 'marc'
LIMIT 1
2026-06-26 13:31:32.452 +00:00 [ERR] Failed executing DbCommand (6ms) [Parameters=[@p0='?' (DbType = Boolean), @p1='?', @p2='?', @p3='?' (DbType = Int32), @p4='?'], CommandType='"Text"', CommandTimeout='30']
INSERT INTO "Users" ("IsTenantAdmin", "PasswordHash", "PasswordSalt", "TenantId", "Username")
VALUES (@p0, @p1, @p2, @p3, @p4)
RETURNING "Id";
2026-06-26 13:31:32.462 +00:00 [ERR] An exception occurred in the database while saving changes for context type 'timetracker.Data.TimetrackerDbContext'.
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
---> Npgsql.PostgresException (0x80004005): 42804: column "IsTenantAdmin" is of type integer but expression is of type boolean
POSITION: 103
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
Exception data:
Severity: ERROR
SqlState: 42804
MessageText: column "IsTenantAdmin" is of type integer but expression is of type boolean
Hint: You will need to rewrite or cast the expression.
Position: 103
File: parse_target.c
Line: 586
Routine: transformAssignedExpr
--- End of inner exception stack trace ---
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalDatabase.SaveChangesAsync(IList`1 entries, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList`1 entriesToSave, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(StateManager stateManager, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
at Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.NpgsqlExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
---> Npgsql.PostgresException (0x80004005): 42804: column "IsTenantAdmin" is of type integer but expression is of type boolean
POSITION: 103
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
Exception data:
Severity: ERROR
SqlState: 42804
MessageText: column "IsTenantAdmin" is of type integer but expression is of type boolean
Hint: You will need to rewrite or cast the expression.
Position: 103
File: parse_target.c
Line: 586
Routine: transformAssignedExpr
--- End of inner exception stack trace ---
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalDatabase.SaveChangesAsync(IList`1 entries, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList`1 entriesToSave, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(StateManager stateManager, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
at Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.NpgsqlExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
2026-06-26 13:31:34.341 +00:00 [INF] File logging active. Writing logs to /app/logs
2026-06-26 13:31:34.731 +00:00 [WRN] Entity 'WorkDay' has a global query filter defined and is the required end of a relationship with the entity 'BreakEntry'. This may lead to unexpected results when the required entity is filtered out. Either configure the navigation as optional, or define matching query filters for both entities in the navigation. See https://go.microsoft.com/fwlink/?linkid=2131316 for more information.
2026-06-26 13:31:34.964 +00:00 [INF] Executed DbCommand (3ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT "MigrationId", "ProductVersion"
FROM "__EFMigrationsHistory"
ORDER BY "MigrationId";
2026-06-26 13:31:34.986 +00:00 [INF] Executed DbCommand (2ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT 1
2026-06-26 13:31:35.057 +00:00 [INF] Executed DbCommand (1ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
"MigrationId" character varying(150) NOT NULL,
"ProductVersion" character varying(32) NOT NULL,
CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId")
);
2026-06-26 13:31:35.067 +00:00 [INF] Executed DbCommand (0ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
LOCK TABLE "__EFMigrationsHistory" IN ACCESS EXCLUSIVE MODE
2026-06-26 13:31:35.068 +00:00 [INF] Executed DbCommand (0ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT "MigrationId", "ProductVersion"
FROM "__EFMigrationsHistory"
ORDER BY "MigrationId";
2026-06-26 13:31:35.072 +00:00 [INF] No migrations were applied. The database is already up to date.
2026-06-26 13:31:35.267 +00:00 [INF] Executed DbCommand (1ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT u."Id", u."IsTenantAdmin", u."PasswordHash", u."PasswordSalt", u."TenantId", u."Username"
FROM "Users" AS u
WHERE u."TenantId" IS NULL AND u."Username" = 'marc'
LIMIT 1
2026-06-26 13:31:35.426 +00:00 [ERR] Failed executing DbCommand (7ms) [Parameters=[@p0='?' (DbType = Boolean), @p1='?', @p2='?', @p3='?' (DbType = Int32), @p4='?'], CommandType='"Text"', CommandTimeout='30']
INSERT INTO "Users" ("IsTenantAdmin", "PasswordHash", "PasswordSalt", "TenantId", "Username")
VALUES (@p0, @p1, @p2, @p3, @p4)
RETURNING "Id";
2026-06-26 13:31:35.440 +00:00 [ERR] An exception occurred in the database while saving changes for context type 'timetracker.Data.TimetrackerDbContext'.
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
---> Npgsql.PostgresException (0x80004005): 42804: column "IsTenantAdmin" is of type integer but expression is of type boolean
POSITION: 103
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
Exception data:
Severity: ERROR
SqlState: 42804
MessageText: column "IsTenantAdmin" is of type integer but expression is of type boolean
Hint: You will need to rewrite or cast the expression.
Position: 103
File: parse_target.c
Line: 586
Routine: transformAssignedExpr
--- End of inner exception stack trace ---
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalDatabase.SaveChangesAsync(IList`1 entries, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList`1 entriesToSave, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(StateManager stateManager, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
at Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.NpgsqlExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
---> Npgsql.PostgresException (0x80004005): 42804: column "IsTenantAdmin" is of type integer but expression is of type boolean
POSITION: 103
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
Exception data:
Severity: ERROR
SqlState: 42804
MessageText: column "IsTenantAdmin" is of type integer but expression is of type boolean
Hint: You will need to rewrite or cast the expression.
Position: 103
File: parse_target.c
Line: 586
Routine: transformAssignedExpr
--- End of inner exception stack trace ---
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalDatabase.SaveChangesAsync(IList`1 entries, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList`1 entriesToSave, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(StateManager stateManager, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
at Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.NpgsqlExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
2026-06-26 13:31:37.974 +00:00 [INF] File logging active. Writing logs to /app/logs
2026-06-26 13:31:38.451 +00:00 [WRN] Entity 'WorkDay' has a global query filter defined and is the required end of a relationship with the entity 'BreakEntry'. This may lead to unexpected results when the required entity is filtered out. Either configure the navigation as optional, or define matching query filters for both entities in the navigation. See https://go.microsoft.com/fwlink/?linkid=2131316 for more information.
2026-06-26 13:31:38.698 +00:00 [INF] Executed DbCommand (3ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT "MigrationId", "ProductVersion"
FROM "__EFMigrationsHistory"
ORDER BY "MigrationId";
2026-06-26 13:31:38.721 +00:00 [INF] Executed DbCommand (2ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT 1
2026-06-26 13:31:38.792 +00:00 [INF] Executed DbCommand (1ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
"MigrationId" character varying(150) NOT NULL,
"ProductVersion" character varying(32) NOT NULL,
CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId")
);
2026-06-26 13:31:38.802 +00:00 [INF] Executed DbCommand (0ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
LOCK TABLE "__EFMigrationsHistory" IN ACCESS EXCLUSIVE MODE
2026-06-26 13:31:38.803 +00:00 [INF] Executed DbCommand (0ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT "MigrationId", "ProductVersion"
FROM "__EFMigrationsHistory"
ORDER BY "MigrationId";
2026-06-26 13:31:38.807 +00:00 [INF] No migrations were applied. The database is already up to date.
2026-06-26 13:31:39.004 +00:00 [INF] Executed DbCommand (1ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT u."Id", u."IsTenantAdmin", u."PasswordHash", u."PasswordSalt", u."TenantId", u."Username"
FROM "Users" AS u
WHERE u."TenantId" IS NULL AND u."Username" = 'marc'
LIMIT 1
2026-06-26 13:31:39.163 +00:00 [ERR] Failed executing DbCommand (6ms) [Parameters=[@p0='?' (DbType = Boolean), @p1='?', @p2='?', @p3='?' (DbType = Int32), @p4='?'], CommandType='"Text"', CommandTimeout='30']
INSERT INTO "Users" ("IsTenantAdmin", "PasswordHash", "PasswordSalt", "TenantId", "Username")
VALUES (@p0, @p1, @p2, @p3, @p4)
RETURNING "Id";
2026-06-26 13:31:39.176 +00:00 [ERR] An exception occurred in the database while saving changes for context type 'timetracker.Data.TimetrackerDbContext'.
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
---> Npgsql.PostgresException (0x80004005): 42804: column "IsTenantAdmin" is of type integer but expression is of type boolean
POSITION: 103
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
Exception data:
Severity: ERROR
SqlState: 42804
MessageText: column "IsTenantAdmin" is of type integer but expression is of type boolean
Hint: You will need to rewrite or cast the expression.
Position: 103
File: parse_target.c
Line: 586
Routine: transformAssignedExpr
--- End of inner exception stack trace ---
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalDatabase.SaveChangesAsync(IList`1 entries, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList`1 entriesToSave, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(StateManager stateManager, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
at Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.NpgsqlExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
---> Npgsql.PostgresException (0x80004005): 42804: column "IsTenantAdmin" is of type integer but expression is of type boolean
POSITION: 103
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
Exception data:
Severity: ERROR
SqlState: 42804
MessageText: column "IsTenantAdmin" is of type integer but expression is of type boolean
Hint: You will need to rewrite or cast the expression.
Position: 103
File: parse_target.c
Line: 586
Routine: transformAssignedExpr
--- End of inner exception stack trace ---
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalDatabase.SaveChangesAsync(IList`1 entries, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList`1 entriesToSave, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(StateManager stateManager, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
at Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.NpgsqlExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
2026-06-26 13:31:41.413 +00:00 [INF] File logging active. Writing logs to /app/logs
2026-06-26 13:31:41.805 +00:00 [WRN] Entity 'WorkDay' has a global query filter defined and is the required end of a relationship with the entity 'BreakEntry'. This may lead to unexpected results when the required entity is filtered out. Either configure the navigation as optional, or define matching query filters for both entities in the navigation. See https://go.microsoft.com/fwlink/?linkid=2131316 for more information.
2026-06-26 13:31:42.040 +00:00 [INF] Executed DbCommand (4ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT "MigrationId", "ProductVersion"
FROM "__EFMigrationsHistory"
ORDER BY "MigrationId";
2026-06-26 13:31:42.062 +00:00 [INF] Executed DbCommand (2ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT 1
2026-06-26 13:31:42.133 +00:00 [INF] Executed DbCommand (0ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
"MigrationId" character varying(150) NOT NULL,
"ProductVersion" character varying(32) NOT NULL,
CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId")
);
2026-06-26 13:31:42.143 +00:00 [INF] Executed DbCommand (0ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
LOCK TABLE "__EFMigrationsHistory" IN ACCESS EXCLUSIVE MODE
2026-06-26 13:31:42.144 +00:00 [INF] Executed DbCommand (0ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT "MigrationId", "ProductVersion"
FROM "__EFMigrationsHistory"
ORDER BY "MigrationId";
2026-06-26 13:31:42.148 +00:00 [INF] No migrations were applied. The database is already up to date.
2026-06-26 13:31:42.342 +00:00 [INF] Executed DbCommand (2ms) [Parameters=[], CommandType='"Text"', CommandTimeout='30']
SELECT u."Id", u."IsTenantAdmin", u."PasswordHash", u."PasswordSalt", u."TenantId", u."Username"
FROM "Users" AS u
WHERE u."TenantId" IS NULL AND u."Username" = 'marc'
LIMIT 1
2026-06-26 13:31:42.501 +00:00 [ERR] Failed executing DbCommand (6ms) [Parameters=[@p0='?' (DbType = Boolean), @p1='?', @p2='?', @p3='?' (DbType = Int32), @p4='?'], CommandType='"Text"', CommandTimeout='30']
INSERT INTO "Users" ("IsTenantAdmin", "PasswordHash", "PasswordSalt", "TenantId", "Username")
VALUES (@p0, @p1, @p2, @p3, @p4)
RETURNING "Id";
2026-06-26 13:31:42.514 +00:00 [ERR] An exception occurred in the database while saving changes for context type 'timetracker.Data.TimetrackerDbContext'.
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
---> Npgsql.PostgresException (0x80004005): 42804: column "IsTenantAdmin" is of type integer but expression is of type boolean
POSITION: 103
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
Exception data:
Severity: ERROR
SqlState: 42804
MessageText: column "IsTenantAdmin" is of type integer but expression is of type boolean
Hint: You will need to rewrite or cast the expression.
Position: 103
File: parse_target.c
Line: 586
Routine: transformAssignedExpr
--- End of inner exception stack trace ---
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalDatabase.SaveChangesAsync(IList`1 entries, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList`1 entriesToSave, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(StateManager stateManager, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
at Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.NpgsqlExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
---> Npgsql.PostgresException (0x80004005): 42804: column "IsTenantAdmin" is of type integer but expression is of type boolean
POSITION: 103
at Npgsql.Internal.NpgsqlConnector.ReadMessageLong(Boolean async, DataRowLoadingMode dataRowLoadingMode, Boolean readingNotifications, Boolean isReadingPrependedMessage)
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlDataReader.NextResult(Boolean async, Boolean isConsuming, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteReader(Boolean async, CommandBehavior behavior, CancellationToken cancellationToken)
at Npgsql.NpgsqlCommand.ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteReaderAsync(RelationalCommandParameterObject parameterObject, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
Exception data:
Severity: ERROR
SqlState: 42804
MessageText: column "IsTenantAdmin" is of type integer but expression is of type boolean
Hint: You will need to rewrite or cast the expression.
Position: 103
File: parse_target.c
Line: 586
Routine: transformAssignedExpr
--- End of inner exception stack trace ---
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Storage.RelationalDatabase.SaveChangesAsync(IList`1 entries, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList`1 entriesToSave, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(StateManager stateManager, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
at Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.NpgsqlExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
2026-06-26 13:31:46.331 +00:00 [INF] File logging active. Writing logs to /app/logs
Und logs vom DB container:
2026-06-26 13:31:42.498 UTC [32] HINT: You will need to rewrite or cast the expression.
2026-06-26 13:31:42.498 UTC [32] STATEMENT: INSERT INTO "Users" ("IsTenantAdmin", "PasswordHash", "PasswordSalt", "TenantId", "Username")
VALUES ($1, $2, $3, $4, $5)
RETURNING "Id"
2026-06-26 13:31:47.069 UTC [33] ERROR: syntax error at or near "public" at character 68
2026-06-26 13:31:47.069 UTC [33] QUERY: ALTER TABLE public."Users" ALTER COLUMN "Id" SET DEFAULT nextval(''public."Users_Id_seq"'')
2026-06-26 13:31:47.069 UTC [33] CONTEXT: PL/pgSQL function inline_code_block line 12 at EXECUTE
2026-06-26 13:31:47.069 UTC [33] STATEMENT:
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'Users'
AND column_name = 'Id'
) THEN
EXECUTE 'CREATE SEQUENCE IF NOT EXISTS public."Users_Id_seq"';
EXECUTE 'ALTER SEQUENCE public."Users_Id_seq" OWNED BY public."Users"."Id"';
EXECUTE 'ALTER TABLE public."Users" ALTER COLUMN "Id" SET DEFAULT nextval(''''public."Users_Id_seq"'''')';
EXECUTE 'SELECT setval(''''public."Users_Id_seq"'''', COALESCE((SELECT MAX("Id") FROM public."Users"), 0) + 1, false)';

END IF;
END $$
2026-06-26 13:31:47.407 UTC [33] ERROR: column "IsTenantAdmin" is of type integer but expression is of type boolean at character 103
2026-06-26 13:31:47.407 UTC [33] HINT: You will need to rewrite or cast the expression.
2026-06-26 13:31:47.407 UTC [33] STATEMENT: INSERT INTO "Users" ("IsTenantAdmin", "PasswordHash", "PasswordSalt", "TenantId", "Username")
VALUES ($1, $2, $3, $4, $5)
RETURNING "Id"
2026-06-26 13:31:55.406 UTC [34] ERROR: syntax error at or near "public" at character 68
2026-06-26 13:31:55.406 UTC [34] QUERY: ALTER TABLE public."Users" ALTER COLUMN "Id" SET DEFAULT nextval(''public."Users_Id_seq"'')
2026-06-26 13:31:55.406 UTC [34] CONTEXT: PL/pgSQL function inline_code_block line 12 at EXECUTE
2026-06-26 13:31:55.406 UTC [34] STATEMENT:
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'Users'
AND column_name = 'Id'
) THEN
EXECUTE 'CREATE SEQUENCE IF NOT EXISTS public."Users_Id_seq"';
EXECUTE 'ALTER SEQUENCE public."Users_Id_seq" OWNED BY public."Users"."Id"';
EXECUTE 'ALTER TABLE public."Users" ALTER COLUMN "Id" SET DEFAULT nextval(''''public."Users_Id_seq"'''')';
EXECUTE 'SELECT setval(''''public."Users_Id_seq"'''', COALESCE((SELECT MAX("Id") FROM public."Users"), 0) + 1, false)';

END IF;
END $$
2026-06-26 13:31:55.737 UTC [34] ERROR: column "IsTenantAdmin" is of type integer but expression is of type boolean at character 103
2026-06-26 13:31:55.737 UTC [34] HINT: You will need to rewrite or cast the expression.
2026-06-26 13:31:55.737 UTC [34] STATEMENT: INSERT INTO "Users" ("IsTenantAdmin", "PasswordHash", "PasswordSalt", "TenantId", "Username")
VALUES ($1, $2, $3, $4, $5)
RETURNING "Id"
2026-06-26 13:32:09.909 UTC [35] ERROR: syntax error at or near "public" at character 68
2026-06-26 13:32:09.909 UTC [35] QUERY: ALTER TABLE public."Users" ALTER COLUMN "Id" SET DEFAULT nextval(''public."Users_Id_seq"'')
2026-06-26 13:32:09.909 UTC [35] CONTEXT: PL/pgSQL function inline_code_block line 12 at EXECUTE
2026-06-26 13:32:09.909 UTC [35] STATEMENT:
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'Users'
AND column_name = 'Id'
) THEN
EXECUTE 'CREATE SEQUENCE IF NOT EXISTS public."Users_Id_seq"';
EXECUTE 'ALTER SEQUENCE public."Users_Id_seq" OWNED BY public."Users"."Id"';
EXECUTE 'ALTER TABLE public."Users" ALTER COLUMN "Id" SET DEFAULT nextval(''''public."Users_Id_seq"'''')';
EXECUTE 'SELECT setval(''''public."Users_Id_seq"'''', COALESCE((SELECT MAX("Id") FROM public."Users"), 0) + 1, false)';

END IF;
END $$
2026-06-26 13:32:10.239 UTC [35] ERROR: column "IsTenantAdmin" is of type integer but expression is of type boolean at character 103
2026-06-26 13:32:10.239 UTC [35] HINT: You will need to rewrite or cast the expression.
2026-06-26 13:32:10.239 UTC [35] STATEMENT: INSERT INTO "Users" ("IsTenantAdmin", "PasswordHash", "PasswordSalt", "TenantId", "Username")
VALUES ($1, $2, $3, $4, $5)
RETURNING "Id"
2026-06-26 13:32:37.184 UTC [36] ERROR: syntax error at or near "public" at character 68
2026-06-26 13:32:37.184 UTC [36] QUERY: ALTER TABLE public."Users" ALTER COLUMN "Id" SET DEFAULT nextval(''public."Users_Id_seq"'')
2026-06-26 13:32:37.184 UTC [36] CONTEXT: PL/pgSQL function inline_code_block line 12 at EXECUTE
2026-06-26 13:32:37.184 UTC [36] STATEMENT:
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'Users'
AND column_name = 'Id'
) THEN
EXECUTE 'CREATE SEQUENCE IF NOT EXISTS public."Users_Id_seq"';
EXECUTE 'ALTER SEQUENCE public."Users_Id_seq" OWNED BY public."Users"."Id"';
EXECUTE 'ALTER TABLE public."Users" ALTER COLUMN "Id" SET DEFAULT nextval(''''public."Users_Id_seq"'''')';
EXECUTE 'SELECT setval(''''public."Users_Id_seq"'''', COALESCE((SELECT MAX("Id") FROM public."Users"), 0) + 1, false)';

END IF;
END $$
2026-06-26 13:32:37.515 UTC [36] ERROR: column "IsTenantAdmin" is of type integer but expression is of type boolean at character 103
2026-06-26 13:32:37.515 UTC [36] HINT: You will need to rewrite or cast the expression.
2026-06-26 13:32:37.515 UTC [36] STATEMENT: INSERT INTO "Users" ("IsTenantAdmin", "PasswordHash", "PasswordSalt", "TenantId", "Username")
VALUES ($1, $2, $3, $4, $5)
RETURNING "Id"
+19 -1
View File
@@ -28,9 +28,27 @@ services:
- DB_PROVIDER=PostgreSQL - DB_PROVIDER=PostgreSQL
- ConnectionStrings__DefaultConnection=Host=db;Database=timetracker;Username=timetracker_user;Password=SecretPassword123; - ConnectionStrings__DefaultConnection=Host=db;Database=timetracker;Username=timetracker_user;Password=SecretPassword123;
- EnableHttpsRedirect=false - EnableHttpsRedirect=false
- TIMETRACKER_LOG_DIR=/app/logs
- TenantSettings__BaseDomain=timetracker.marc-wieland.de
volumes:
- dpkeys:/home/app/.aspnet/DataProtection-Keys
- applogs:/app/logs
depends_on: depends_on:
- db - db
logshell:
image: bash:5.2
container_name: timetracker-logshell
restart: unless-stopped
working_dir: /logs
command: ["bash", "-lc", "tail -f /dev/null"]
volumes:
- applogs:/logs
volumes: volumes:
pgdata: pgdata:
name: timetracker_pgdata name: timetracker_pgdata_fresh
dpkeys:
name: timetracker_dpkeys
applogs:
name: timetracker_logs
+16
View File
@@ -0,0 +1,16 @@
@echo off
setlocal
echo === 1. Bereinige alte Build-Dateien ===
dotnet clean
if errorlevel 1 exit /b %errorlevel%
echo === 2. Baue die gesamte Solution ===
dotnet build
if errorlevel 1 exit /b %errorlevel%
echo === 3. Starte den Server ^& oeffne Browser ===
start "timetracker-browser" powershell -NoProfile -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:5065'"
dotnet run --project timetracker.Server\timetracker.Server.csproj
exit /b %errorlevel%
+3
View File
@@ -0,0 +1,3 @@
using System;
using PdfSharp.Fonts;
class Program { static void Main() { Console.WriteLine("Hello"); } }
@@ -29,7 +29,7 @@
<AuthorizeView> <AuthorizeView>
<Authorized> <Authorized>
<MudDrawer @bind-Open="_drawerOpen" ClipMode="DrawerClipMode.Never" Elevation="0" Variant="DrawerVariant.Mini" OpenMiniOnHover="false" Class="modern-drawer"> <MudDrawer @bind-Open="_drawerOpen" ClipMode="DrawerClipMode.Never" Elevation="0" Variant="DrawerVariant.Mini" OpenMiniOnHover="false" Class="modern-drawer">
<NavMenu OnToggleDrawer="ToggleDrawer" /> <NavMenu OnToggleDrawer="ToggleDrawer" IsCollapsed="!_drawerOpen" />
</MudDrawer> </MudDrawer>
</Authorized> </Authorized>
</AuthorizeView> </AuthorizeView>
@@ -48,6 +48,9 @@
</MudLayout> </MudLayout>
@code { @code {
[PersistentState(AllowUpdates = true)]
public TenantInfo? PersistentTenantInfo { get; set; }
private bool _drawerOpen = true; private bool _drawerOpen = true;
private bool _isDarkMode; private bool _isDarkMode;
@@ -55,44 +58,60 @@
{ {
UserNotificationService.OnUserDeleted += HandleUserDeleted; UserNotificationService.OnUserDeleted += HandleUserDeleted;
try if (PersistentTenantInfo != null)
{ {
var tenantInfo = await Http.GetFromJsonAsync<TenantInfo>("api/tenant/current"); ApplyTheme(PersistentTenantInfo);
if (tenantInfo?.IsTenant == true && !string.IsNullOrEmpty(tenantInfo.PrimaryColor)) }
else
{
try
{ {
var primaryColor = tenantInfo.PrimaryColor; var tenantInfo = await Http.GetFromJsonAsync<TenantInfo>("api/tenant/current");
var secondaryColor = tenantInfo.SecondaryColor ?? tenantInfo.PrimaryColor; if (tenantInfo != null)
{
_theme.PaletteLight.Primary = primaryColor; ApplyTheme(tenantInfo);
_theme.PaletteLight.Secondary = secondaryColor; PersistentTenantInfo = tenantInfo;
_theme.PaletteDark.Primary = primaryColor; }
_theme.PaletteDark.Secondary = secondaryColor; }
catch
// Dynamically apply background colors to navbar {
_theme.PaletteLight.AppbarBackground = primaryColor; // Ignore background error
_theme.PaletteLight.DrawerBackground = primaryColor;
_theme.PaletteDark.AppbarBackground = primaryColor;
_theme.PaletteDark.DrawerBackground = primaryColor;
// Adjust text/icon contrast based on background luminance
var isLight = IsLightColor(primaryColor);
var textCol = isLight ? "#0F172A" : "#FFFFFF";
var subTextCol = isLight ? "rgba(15, 23, 42, 0.70)" : "rgba(255, 255, 255, 0.70)";
_theme.PaletteLight.AppbarText = textCol;
_theme.PaletteLight.DrawerText = subTextCol;
_theme.PaletteLight.DrawerIcon = subTextCol;
_theme.PaletteLight.PrimaryContrastText = textCol;
_theme.PaletteDark.AppbarText = textCol;
_theme.PaletteDark.DrawerText = subTextCol;
_theme.PaletteDark.DrawerIcon = subTextCol;
_theme.PaletteDark.PrimaryContrastText = textCol;
} }
} }
catch }
private void ApplyTheme(TenantInfo tenantInfo)
{
if (tenantInfo.IsTenant && !string.IsNullOrEmpty(tenantInfo.PrimaryColor))
{ {
// Ignore background error var primaryColor = tenantInfo.PrimaryColor;
var secondaryColor = tenantInfo.SecondaryColor ?? tenantInfo.PrimaryColor;
_theme.PaletteLight.Primary = primaryColor;
_theme.PaletteLight.Secondary = secondaryColor;
_theme.PaletteDark.Primary = primaryColor;
_theme.PaletteDark.Secondary = secondaryColor;
// Dynamically apply background colors to navbar
_theme.PaletteLight.AppbarBackground = primaryColor;
_theme.PaletteLight.DrawerBackground = primaryColor;
_theme.PaletteDark.AppbarBackground = primaryColor;
_theme.PaletteDark.DrawerBackground = primaryColor;
// Adjust text/icon contrast based on background luminance
var isLight = IsLightColor(primaryColor);
var textCol = isLight ? "#0F172A" : "#FFFFFF";
var subTextCol = isLight ? "rgba(15, 23, 42, 0.70)" : "rgba(255, 255, 255, 0.70)";
_theme.PaletteLight.AppbarText = textCol;
_theme.PaletteLight.DrawerText = subTextCol;
_theme.PaletteLight.DrawerIcon = subTextCol;
_theme.PaletteLight.PrimaryContrastText = textCol;
_theme.PaletteDark.AppbarText = textCol;
_theme.PaletteDark.DrawerText = subTextCol;
_theme.PaletteDark.DrawerIcon = subTextCol;
_theme.PaletteDark.PrimaryContrastText = textCol;
} }
} }
@@ -1,10 +1,10 @@
@inject IJSRuntime JSRuntime @inject IJSRuntime JSRuntime
@inject HttpClient Http @inject HttpClient Http
<div class="nav-container onboarding-nav-menu"> <div class="nav-container onboarding-nav-menu @(IsCollapsed ? "collapsed" : "")">
@* --- Brand / Logo Section --- *@ @* --- Brand / Logo Section --- *@
<div class="nav-header"> <div class="nav-header">
<div class="logo-wrapper"> <div class="logo-wrapper">
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" OnClick="OnToggleDrawer" Class="drawer-toggle-btn mr-1" /> <MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" OnClick="OnToggleDrawer" Class="drawer-toggle-btn mr-1 d-none d-md-flex" />
<MudIcon Icon="@Icons.Material.Filled.AccessTime" Class="logo-icon" /> <MudIcon Icon="@Icons.Material.Filled.AccessTime" Class="logo-icon" />
<span class="brand-text">Timetracker</span> <span class="brand-text">Timetracker</span>
</div> </div>
@@ -13,8 +13,11 @@
@* --- Navigation Links --- *@ @* --- Navigation Links --- *@
<div class="nav-content"> <div class="nav-content">
<MudNavMenu Class="custom-nav-menu"> <MudNavMenu Class="custom-nav-menu">
<MudTooltip Text="Dashboard" Placement="Placement.Right">
<MudNavLink Href="" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard" Class="custom-nav-link">Dashboard</MudNavLink>
</MudTooltip>
<MudTooltip Text="Wochenübersicht" Placement="Placement.Right"> <MudTooltip Text="Wochenübersicht" Placement="Placement.Right">
<MudNavLink Href="" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.CalendarMonth" Class="custom-nav-link">Wochenübersicht</MudNavLink> <MudNavLink Href="week" Icon="@Icons.Material.Filled.CalendarMonth" Class="custom-nav-link">Wochenübersicht</MudNavLink>
</MudTooltip> </MudTooltip>
<MudTooltip Text="Monatsübersicht" Placement="Placement.Right"> <MudTooltip Text="Monatsübersicht" Placement="Placement.Right">
<MudNavLink Href="month" Icon="@Icons.Material.Filled.CalendarViewMonth" Class="custom-nav-link">Monatsübersicht</MudNavLink> <MudNavLink Href="month" Icon="@Icons.Material.Filled.CalendarViewMonth" Class="custom-nav-link">Monatsübersicht</MudNavLink>
@@ -96,24 +99,36 @@
</div> </div>
@code { @code {
[PersistentState(AllowUpdates = true)]
public string? PersistentCompanyName { get; set; }
[Parameter] public EventCallback OnToggleDrawer { get; set; } [Parameter] public EventCallback OnToggleDrawer { get; set; }
[Parameter] public bool IsCollapsed { get; set; }
private bool _showVersionBadge; private bool _showVersionBadge;
private string _companyName = "Hauptdomain"; private string _companyName = "Hauptdomain";
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
try if (PersistentCompanyName != null)
{ {
var tenantInfo = await Http.GetFromJsonAsync<TenantInfo>("api/tenant/current"); _companyName = PersistentCompanyName;
if (tenantInfo?.IsTenant == true && !string.IsNullOrEmpty(tenantInfo.Name))
{
_companyName = tenantInfo.Name;
}
} }
catch else
{ {
// Ignore background error try
{
var tenantInfo = await Http.GetFromJsonAsync<TenantInfo>("api/tenant/current");
if (tenantInfo?.IsTenant == true && !string.IsNullOrEmpty(tenantInfo.Name))
{
_companyName = tenantInfo.Name;
PersistentCompanyName = _companyName;
}
}
catch
{
// Ignore background error
}
} }
} }
@@ -118,41 +118,61 @@
color: var(--mud-palette-appbar-text) !important; color: var(--mud-palette-appbar-text) !important;
} }
/* NavLink active state (Fixes active color contrast issue) */ /* NavLink active state (Circle around icon) */
::deep .custom-nav-link.active, ::deep .custom-nav-link.active,
::deep .custom-nav-link .active { ::deep .custom-nav-link .active {
background: linear-gradient(90deg, color-mix(in srgb, var(--mud-palette-secondary) 15%, transparent) 0%, color-mix(in srgb, var(--mud-palette-secondary) 4%, transparent) 100%) !important; background: transparent !important;
font-weight: 600 !important; font-weight: 600 !important;
box-shadow: inset 3px 0 0 var(--mud-palette-secondary) !important; box-shadow: none !important;
} }
::deep .custom-nav-link.active,
::deep .custom-nav-link .active,
::deep .custom-nav-link.active .mud-nav-link-text, ::deep .custom-nav-link.active .mud-nav-link-text,
::deep .custom-nav-link .active .mud-nav-link-text, ::deep .custom-nav-link .active .mud-nav-link-text {
::deep .custom-nav-link.active .mud-nav-link-icon-default,
::deep .custom-nav-link .active .mud-nav-link-icon-default {
color: var(--mud-palette-secondary) !important; color: var(--mud-palette-secondary) !important;
} }
/* Admin link color overrides & active state */ ::deep .custom-nav-link.active .mud-nav-link-icon-default,
::deep .custom-nav-link .active .mud-nav-link-icon-default {
color: var(--mud-palette-secondary) !important;
background-color: color-mix(in srgb, var(--mud-palette-secondary) 15%, transparent) !important;
border-radius: 50% !important;
padding: 6px !important;
margin: -6px 8px -6px -6px !important;
width: 36px !important;
height: 36px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
/* Admin link active state (Circle around icon) */
::deep .admin-link .mud-nav-link-icon-default { ::deep .admin-link .mud-nav-link-icon-default {
color: rgba(239, 68, 68, 0.7) !important; /* Red 500 */ color: rgba(239, 68, 68, 0.7) !important; /* Red 500 */
} }
::deep .admin-link.active, ::deep .admin-link.active,
::deep .admin-link .active { ::deep .admin-link .active {
background: linear-gradient(90deg, rgba(239, 68, 68, 0.15) 0%, rgba(239, 68, 68, 0.04) 100%) !important; background: transparent !important;
box-shadow: inset 3px 0 0 #EF4444 !important; box-shadow: none !important;
} }
::deep .admin-link.active,
::deep .admin-link .active,
::deep .admin-link.active .mud-nav-link-text, ::deep .admin-link.active .mud-nav-link-text,
::deep .admin-link .active .mud-nav-link-text, ::deep .admin-link .active .mud-nav-link-text {
color: #EF4444 !important;
}
::deep .admin-link.active .mud-nav-link-icon-default, ::deep .admin-link.active .mud-nav-link-icon-default,
::deep .admin-link .active .mud-nav-link-icon-default { ::deep .admin-link .active .mud-nav-link-icon-default {
color: #EF4444 !important; color: #EF4444 !important;
background-color: rgba(239, 68, 68, 0.15) !important;
border-radius: 50% !important;
padding: 6px !important;
margin: -6px 8px -6px -6px !important;
width: 36px !important;
height: 36px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
} }
/* Version link active state */ /* Version link active state */
@@ -295,63 +315,89 @@
/* --- COLLAPSED DRAWER (MINI STATE) STYLES --- */ /* --- COLLAPSED DRAWER (MINI STATE) STYLES --- */
/* Parent matches the mini drawer element (Note the correct Blazor CSS isolation placement) */ /* Smooth transitions for all elements that change */
.mud-drawer--closed ::deep .logo-icon, ::deep .logo-icon, .brand-text, .admin-divider-container, .admin-divider-text, .admin-divider-line, .user-profile-card, .user-info, .version-text, ::deep .custom-nav-link .mud-nav-link-text {
.mud-drawer--closed ::deep .brand-text { transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: none !important; }
.collapsed ::deep .logo-icon,
.collapsed .brand-text {
opacity: 0; opacity: 0;
width: 0;
max-width: 0;
overflow: hidden;
margin: 0;
padding: 0;
transform: scale(0);
} }
.mud-drawer--closed ::deep .logo-wrapper { .collapsed .logo-wrapper {
justify-content: center !important; justify-content: center;
gap: 0;
} }
.mud-drawer--closed ::deep .drawer-toggle-btn { .collapsed ::deep .drawer-toggle-btn {
margin-left: 0 !important; margin-left: 0 !important;
} }
.mud-drawer--closed ::deep .admin-divider-container { .collapsed .admin-divider-container {
padding: 16px 0 8px 0 !important; padding: 16px 0 8px 0;
justify-content: center !important; justify-content: center;
} }
.mud-drawer--closed ::deep .admin-divider-text { .collapsed .admin-divider-text {
display: none !important; opacity: 0;
width: 0;
overflow: hidden;
margin: 0;
} }
.mud-drawer--closed ::deep .admin-divider-line { .collapsed .admin-divider-line {
width: 24px !important; width: 24px;
flex: none !important; flex: none;
} }
.mud-drawer--closed ::deep .user-profile-card { .collapsed .user-profile-card {
justify-content: center !important; justify-content: center;
padding: 8px 0 !important; padding: 8px 0;
gap: 0;
} }
.mud-drawer--closed ::deep .user-info { .collapsed .user-info {
display: none !important; opacity: 0;
width: 0;
overflow: hidden;
margin: 0;
} }
.mud-drawer--closed ::deep .version-text { .collapsed .version-text {
display: none !important; opacity: 0;
width: 0;
overflow: hidden;
margin: 0;
} }
/* Ensure link text is hidden in mini mode to prevent clunky clipping */ /* Hide link text but keep smooth transition by manipulating width/opacity if possible, but MudBlazor's DOM might require display none after transition. We'll use opacity/width/overflow. */
.mud-drawer--closed ::deep .custom-nav-link .mud-nav-link-text { .collapsed ::deep .custom-nav-link .mud-nav-link-text {
display: none !important; opacity: 0;
width: 0;
overflow: hidden;
margin: 0 !important;
white-space: nowrap;
} }
/* Center link icons when mini and adjust spacing */ /* Center link icons when mini and adjust spacing */
.mud-drawer--closed ::deep .custom-nav-link { .collapsed ::deep .custom-nav-link {
margin: 4px 6px !important; margin: 4px auto !important;
padding: 8px 0 !important; padding: 8px 0 !important;
justify-content: center !important; justify-content: center !important;
min-height: 40px !important; width: 44px !important;
min-height: 44px !important;
display: flex !important; display: flex !important;
align-items: center !important; align-items: center !important;
border-radius: 12px !important;
} }
.mud-drawer--closed ::deep .custom-nav-link .mud-nav-link-icon-default { .collapsed ::deep .custom-nav-link .mud-nav-link-icon-default {
margin-right: 0 !important; margin-right: 0 !important;
} }
@@ -0,0 +1,61 @@
@using System.Globalization
@using MudBlazor
<MudDialog>
<DialogContent>
<MudStack Spacing="3" Class="pa-2">
<MudSelect @bind-Value="_selectedYear" Label="Jahr" Variant="Variant.Outlined">
@foreach (var year in _years)
{
<MudSelectItem Value="@year">@year</MudSelectItem>
}
</MudSelect>
<MudSelect @bind-Value="_selectedMonth" Label="Monat" Variant="Variant.Outlined">
@foreach (var month in _months)
{
<MudSelectItem Value="@month.Value">@month.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Abbrechen</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="Submit">Herunterladen</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] IMudDialogInstance MudDialog { get; set; } = null!;
private int _selectedYear = DateTime.Today.Year;
private int _selectedMonth = DateTime.Today.Month;
private List<int> _years = Enumerable.Range(DateTime.Today.Year - 5, 6).OrderByDescending(y => y).ToList();
private class MonthOption
{
public int Value { get; set; }
public string Name { get; set; } = "";
}
private List<MonthOption> _months = [];
protected override void OnInitialized()
{
var deCulture = new CultureInfo("de-DE");
_months = Enumerable.Range(1, 12).Select(m => new MonthOption
{
Value = m,
Name = deCulture.DateTimeFormat.GetMonthName(m)
}).ToList();
}
public class MonthSelectorResult
{
public int Year { get; set; }
public int Month { get; set; }
}
void Submit() => MudDialog.Close(DialogResult.Ok(new MonthSelectorResult { Year = _selectedYear, Month = _selectedMonth }));
void Cancel() => MudDialog.Cancel();
}
@@ -9,6 +9,7 @@
@inject IUserNotificationService UserNotificationService @inject IUserNotificationService UserNotificationService
@inject HttpClient Http @inject HttpClient Http
@inject IDialogService DialogService @inject IDialogService DialogService
@inject NavigationManager NavigationManager
@implements IDisposable @implements IDisposable
<PageTitle>Benutzerverwaltung Timetracker</PageTitle> <PageTitle>Benutzerverwaltung Timetracker</PageTitle>
@@ -131,6 +132,12 @@ else
Size="Size.Small" Size="Size.Small"
OnClick="@(() => OpenResetPasswordDialog(context))" /> OnClick="@(() => OpenResetPasswordDialog(context))" />
</MudTooltip> </MudTooltip>
<MudTooltip Text="Stundenzettel (PDF) herunterladen">
<MudIconButton Icon="@Icons.Material.Filled.PictureAsPdf"
Color="Color.Info"
Size="Size.Small"
OnClick="@(() => DownloadUserPdf(context))" />
</MudTooltip>
@if (context.Username != "marc") @if (context.Username != "marc")
{ {
<MudIconButton Icon="@Icons.Material.Filled.DeleteOutline" <MudIconButton Icon="@Icons.Material.Filled.DeleteOutline"
@@ -152,6 +159,12 @@ else
} }
@code { @code {
[PersistentState(AllowUpdates = true)]
public List<User>? PersistentUsers { get; set; }
[PersistentState(AllowUpdates = true)]
public List<TenantStats>? PersistentTenants { get; set; }
private List<User> _users = []; private List<User> _users = [];
private List<TenantStats> _tenants = []; private List<TenantStats> _tenants = [];
private bool _loading = true; private bool _loading = true;
@@ -161,23 +174,35 @@ else
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
if (PersistentUsers != null)
{
_users = PersistentUsers;
_tenants = PersistentTenants ?? [];
_loading = false;
}
var claim = (await AuthStateProvider.GetAuthenticationStateAsync()) var claim = (await AuthStateProvider.GetAuthenticationStateAsync())
.User.FindFirst(ClaimTypes.NameIdentifier); .User.FindFirst(ClaimTypes.NameIdentifier);
if (claim == null) return; if (claim == null) return;
UserNotificationService.OnUsersChanged += RefreshUsers; UserNotificationService.OnUsersChanged += RefreshUsers;
try if (PersistentUsers == null)
{ {
_users = await AuthService.GetAllUsersAsync(); try
_tenants = await Http.GetFromJsonAsync<List<TenantStats>>("api/superadmin/tenants") ?? []; {
_users = await AuthService.GetAllUsersAsync();
_tenants = await Http.GetFromJsonAsync<List<TenantStats>>("api/superadmin/tenants") ?? [];
PersistentUsers = _users;
PersistentTenants = _tenants;
_loading = false;
}
catch (Exception ex)
{
Snackbar.Add($"Fehler beim Laden der Daten: {ex.Message}", Severity.Error);
}
} }
catch (Exception ex)
{
Snackbar.Add($"Fehler beim Laden der Daten: {ex.Message}", Severity.Error);
}
_loading = false;
} }
private async Task RefreshUsers() private async Task RefreshUsers()
@@ -279,4 +304,19 @@ else
} }
} }
} }
private async Task DownloadUserPdf(User user)
{
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true };
var dialog = await DialogService.ShowAsync<MonthSelectorDialog>($"Stundenzettel für {user.Username}", options);
var result = await dialog.Result;
if (result != null && !result.Canceled && result.Data is MonthSelectorDialog.MonthSelectorResult data)
{
int year = data.Year;
int month = data.Month;
NavigationManager.NavigateTo($"api/tracker/export/month/pdf?userId={user.Id}&year={year}&month={month}", forceLoad: true);
}
}
} }
@@ -60,15 +60,19 @@
public static readonly List<Release> Releases = public static readonly List<Release> Releases =
[ [
new("1.5", "25.06.2026", true, new("1.7", "27.06.2026", true,
[
new("Neu", "Excel-Export"),
new("Upgrade", "Besseres Page-Loading")
], Highlighted: true),
new("1.6", "25.06.2026", true,
[ [
new("Neu", "Userprofil angelegt"), new("Neu", "Userprofil angelegt"),
new("Upgrade", "Früheste Gehenszeit wird angezeigt"), new("Upgrade", "Früheste Gehenszeit wird angezeigt"),
new("Upgrade", "Pausen werden dynamisch berechnet"), new("Upgrade", "Pausen werden dynamisch berechnet"),
new("Neu", "Excel-Export"),
new("Neu", "Multi-Tenant Integration"), new("Neu", "Multi-Tenant Integration"),
new("Upgrade", "Verbesserte Single-User Ansicht") new("Upgrade", "Verbesserte Single-User Ansicht")
], Highlighted: true), ]),
new("1.4", "08.06.2026", true, new("1.4", "08.06.2026", true,
[ [
new("Neu", "Timebot implementiert"), new("Neu", "Timebot implementiert"),
@@ -0,0 +1,230 @@
@page "/"
@rendermode InteractiveWebAssembly
@attribute [Authorize]
@inject ITimetrackerService TrackerService
@inject AuthenticationStateProvider AuthStateProvider
@inject IJSRuntime JSRuntime
<PageTitle>Dashboard Timetracker</PageTitle>
@if (_loading)
{
<MudStack AlignItems="AlignItems.Center" Class="mt-16" Spacing="3">
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Large" />
<MudText Color="Color.Secondary">Lade Dashboard…</MudText>
</MudStack>
}
else
{
<MudStack Spacing="4">
@* ── Header & Einstellungen ── *@
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudMenu Icon="@Icons.Material.Filled.Settings" AnchorOrigin="Origin.BottomRight" TransformOrigin="Origin.TopRight" Dense="true" Color="Color.Secondary">
<MudMenuItem OnClick="@(() => ToggleWidget("EarlyBird"))">
<MudCheckBox T="bool" Value="@_showEarlyBird" Label="Early Bird anzeigen" Color="Color.Primary" ReadOnly="true"/>
</MudMenuItem>
<MudMenuItem OnClick="@(() => ToggleWidget("NightOwl"))">
<MudCheckBox T="bool" Value="@_showNightOwl" Label="Night Owl anzeigen" Color="Color.Primary" ReadOnly="true"/>
</MudMenuItem>
<MudMenuItem OnClick="@(() => ToggleWidget("Pausenkonig"))">
<MudCheckBox T="bool" Value="@_showPausenkonig" Label="Pausenkönig anzeigen" Color="Color.Primary" ReadOnly="true"/>
</MudMenuItem>
<MudMenuItem OnClick="@(() => ToggleWidget("Streaks"))">
<MudCheckBox T="bool" Value="@_showStreaks" Label="Streaks anzeigen" Color="Color.Primary" ReadOnly="true"/>
</MudMenuItem>
<MudDivider />
<MudMenuItem OnClick="@(() => ToggleWidget("Charts"))">
<MudCheckBox T="bool" Value="@_showCharts" Label="Diagramme anzeigen" Color="Color.Primary" ReadOnly="true"/>
</MudMenuItem>
</MudMenu>
</MudStack>
@* ── Gamification Widgets ── *@
<MudGrid>
@if (_showEarlyBird)
{
<MudItem xs="12" sm="6" md="3">
<MudPaper Elevation="2" Class="pa-4 rounded-xl" Style="background: linear-gradient(135deg, #fdfbfb 0%, #ebedee 100%);">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
<MudAvatar Color="Color.Warning" Variant="Variant.Filled" Size="Size.Large">🌅</MudAvatar>
<MudStack Spacing="0">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">Early Bird</MudText>
<MudText Typo="Typo.h5" Style="font-weight: 700">@FormatTimeSpan(_stats.EarliestStart)</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Frühester Beginn im Monat</MudText>
</MudStack>
</MudStack>
</MudPaper>
</MudItem>
}
@if (_showNightOwl)
{
<MudItem xs="12" sm="6" md="3">
<MudPaper Elevation="2" Class="pa-4 rounded-xl" Style="background: linear-gradient(135deg, #304352 0%, #d7d2cc 100%); color: white;">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
<MudAvatar Color="Color.Dark" Variant="Variant.Filled" Size="Size.Large">🦉</MudAvatar>
<MudStack Spacing="0">
<MudText Typo="Typo.subtitle2" Style="opacity: 0.8">Night Owl</MudText>
<MudText Typo="Typo.h5" Style="font-weight: 700">@FormatTimeSpan(_stats.LatestEnd)</MudText>
<MudText Typo="Typo.caption" Style="opacity: 0.8">Spätestes Ende im Monat</MudText>
</MudStack>
</MudStack>
</MudPaper>
</MudItem>
}
@if (_showPausenkonig)
{
<MudItem xs="12" sm="6" md="3">
<MudPaper Elevation="2" Class="pa-4 rounded-xl" Style="background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
<MudAvatar Color="Color.Info" Variant="Variant.Filled" Size="Size.Large">☕</MudAvatar>
<MudStack Spacing="0">
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">Pausenkönig</MudText>
<MudText Typo="Typo.h5" Style="font-weight: 700">@((int)_stats.AverageBreak.TotalMinutes) Min.</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Ø Pause im Monat</MudText>
</MudStack>
</MudStack>
</MudPaper>
</MudItem>
}
@if (_showStreaks)
{
<MudItem xs="12" sm="6" md="3">
<MudPaper Elevation="2" Class="pa-4 rounded-xl" Style="background: linear-gradient(135deg, #f6d365 0%, #fda085 100%); color: white;">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
<MudAvatar Color="Color.Error" Variant="Variant.Filled" Size="Size.Large">🔥</MudAvatar>
<MudStack Spacing="0">
<MudText Typo="Typo.subtitle2" Style="opacity: 0.9">Streak</MudText>
<MudText Typo="Typo.h5" Style="font-weight: 700">@_stats.CurrentStreak / @_stats.LongestStreak</MudText>
<MudText Typo="Typo.caption" Style="opacity: 0.9">Aktuell / Bester (Tage)</MudText>
</MudStack>
</MudStack>
</MudPaper>
</MudItem>
}
</MudGrid>
@* ── Diagramme ── *@
@if (_showCharts)
{
<MudGrid>
<MudItem xs="12" md="8">
<MudPaper Elevation="2" Class="pa-4 rounded-xl h-100">
<MudText Typo="Typo.h6" Class="mb-4">Gleitzeit-Verlauf @DateTime.Today.Year</MudText>
<MudChart T="double" ChartType="ChartType.Bar"
ChartSeries="@_barChartSeries"
XAxisLabels="@_barChartLabels"
Width="100%" Height="300px" />
</MudPaper>
</MudItem>
<MudItem xs="12" md="4">
<MudPaper Elevation="2" Class="pa-4 rounded-xl h-100">
<MudText Typo="Typo.h6" Class="mb-4">Jahres-Verteilung</MudText>
<MudChart T="double" ChartType="ChartType.Pie"
InputData="@_pieChartData"
InputLabels="@_pieChartLabels"
Width="100%" Height="300px" />
</MudPaper>
</MudItem>
</MudGrid>
}
</MudStack>
}
@code {
private bool _loading = true;
private DashboardStatsVm _stats = new();
private DashboardChartDataVm _chartData = new();
private bool _showEarlyBird = true;
private bool _showNightOwl = true;
private bool _showPausenkonig = true;
private bool _showStreaks = true;
private bool _showCharts = true;
private List<ChartSeries<double>> _barChartSeries = new();
private string[] _barChartLabels = Array.Empty<string>();
private double[] _pieChartData = Array.Empty<double>();
private string[] _pieChartLabels = Array.Empty<string>();
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var claim = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
if (claim == null) return;
int userId = int.Parse(claim.Value);
int year = DateTime.Today.Year;
int month = DateTime.Today.Month;
await LoadSettings();
_stats = await TrackerService.GetDashboardStatsAsync(userId, year, month);
_chartData = await TrackerService.GetDashboardChartDataAsync(userId, year);
// Prepare Bar Chart
_barChartSeries = new List<ChartSeries<double>>
{
new ChartSeries<double> { Name = "Überstunden", Data = _chartData.MonthlyOvertime.Select(m => m.OvertimeHours).ToArray() }
};
_barChartLabels = _chartData.MonthlyOvertime.Select(m => m.MonthName).ToArray();
// Prepare Pie Chart
_pieChartData = new double[] { _chartData.WorkDaysCount, _chartData.VacationDaysCount, _chartData.PublicHolidaysCount };
_pieChartLabels = new string[] { "Arbeit", "Urlaub", "Feiertage" };
_loading = false;
}
private async Task LoadSettings()
{
try
{
var eb = await JSRuntime.InvokeAsync<string>("localStorage.getItem", "widget_EarlyBird");
if (eb != null) _showEarlyBird = eb == "true";
var no = await JSRuntime.InvokeAsync<string>("localStorage.getItem", "widget_NightOwl");
if (no != null) _showNightOwl = no == "true";
var pk = await JSRuntime.InvokeAsync<string>("localStorage.getItem", "widget_Pausenkonig");
if (pk != null) _showPausenkonig = pk == "true";
var st = await JSRuntime.InvokeAsync<string>("localStorage.getItem", "widget_Streaks");
if (st != null) _showStreaks = st == "true";
var ch = await JSRuntime.InvokeAsync<string>("localStorage.getItem", "widget_Charts");
if (ch != null) _showCharts = ch == "true";
}
catch
{
// SSR Fallback
}
}
private async Task ToggleWidget(string widget)
{
bool newState = true;
if (widget == "EarlyBird") { _showEarlyBird = !_showEarlyBird; newState = _showEarlyBird; }
if (widget == "NightOwl") { _showNightOwl = !_showNightOwl; newState = _showNightOwl; }
if (widget == "Pausenkonig") { _showPausenkonig = !_showPausenkonig; newState = _showPausenkonig; }
if (widget == "Streaks") { _showStreaks = !_showStreaks; newState = _showStreaks; }
if (widget == "Charts") { _showCharts = !_showCharts; newState = _showCharts; }
try
{
await JSRuntime.InvokeVoidAsync("localStorage.setItem", $"widget_{widget}", newState.ToString().ToLower());
}
catch { }
}
private string FormatTimeSpan(TimeSpan? ts)
{
if (!ts.HasValue) return "-";
return $"{ts.Value.Hours:D2}:{ts.Value.Minutes:D2}";
}
}
@@ -183,6 +183,18 @@ else
@code { @code {
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE"); private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
[PersistentState(AllowUpdates = true)]
public AppSettings? PersistentSettings { get; set; }
[PersistentState(AllowUpdates = true)]
public List<PublicHoliday>? PersistentHolidays { get; set; }
[PersistentState(AllowUpdates = true)]
public string? PersistentSubLabel { get; set; }
[PersistentState(AllowUpdates = true)]
public int? PersistentYear { get; set; }
private bool _loading = true; private bool _loading = true;
private int _year = DateTime.Today.Year; private int _year = DateTime.Today.Year;
private List<PublicHoliday> _holidays = []; private List<PublicHoliday> _holidays = [];
@@ -193,15 +205,38 @@ else
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
if (PersistentSettings != null && PersistentHolidays != null)
{
_settings = PersistentSettings;
_holidays = PersistentHolidays;
_subLabel = PersistentSubLabel ?? "";
_year = PersistentYear ?? DateTime.Today.Year;
_loading = false;
}
var authState = await AuthStateProvider.GetAuthenticationStateAsync(); var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var claim = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier); var claim = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
if (claim != null) if (claim != null)
{ {
_userId = int.Parse(claim.Value); _userId = int.Parse(claim.Value);
_settings = await TrackerService.GetSettingsAsync(_userId);
if (PersistentSettings == null || PersistentHolidays == null)
{
_settings = await TrackerService.GetSettingsAsync(_userId);
await LoadHolidays();
PersistentSettings = _settings;
PersistentHolidays = _holidays;
PersistentSubLabel = _subLabel;
PersistentYear = _year;
_loading = false;
}
}
else
{
await LoadHolidays();
_loading = false;
} }
await LoadHolidays();
_loading = false;
} }
private async Task LoadHolidays() private async Task LoadHolidays()
+60 -11
View File
@@ -1,4 +1,4 @@
@page "/" @page "/week"
@rendermode InteractiveWebAssembly @rendermode InteractiveWebAssembly
@attribute [Authorize] @attribute [Authorize]
@inject ITimetrackerService TrackerService @inject ITimetrackerService TrackerService
@@ -388,6 +388,30 @@ else
@code { @code {
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE"); private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
[PersistentState(AllowUpdates = true)]
public AppSettings? PersistentSettings { get; set; }
[PersistentState(AllowUpdates = true)]
public List<DayVm>? PersistentDays { get; set; }
[PersistentState(AllowUpdates = true)]
public TimeSpan? PersistentTotalOvertime { get; set; }
[PersistentState(AllowUpdates = true)]
public Dictionary<DateOnly, string>? PersistentHolidays { get; set; }
[PersistentState(AllowUpdates = true)]
public int? PersistentHolidayYear { get; set; }
[PersistentState(AllowUpdates = true)]
public List<VacationDay>? PersistentVacations { get; set; }
[PersistentState(AllowUpdates = true)]
public string? PersistentWeekLabel { get; set; }
[PersistentState(AllowUpdates = true)]
public string? PersistentWeekSubLabel { get; set; }
private bool _loading = true; private bool _loading = true;
private int _userId; private int _userId;
private DateOnly _monday; private DateOnly _monday;
@@ -409,18 +433,45 @@ else
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
if (PersistentSettings != null && PersistentDays != null && PersistentTotalOvertime != null)
{
_settings = PersistentSettings;
_days = PersistentDays;
_totalOvertime = PersistentTotalOvertime.Value;
_holidays = PersistentHolidays ?? [];
_holidayYear = PersistentHolidayYear ?? -1;
_vacations = PersistentVacations ?? [];
_weekLabel = PersistentWeekLabel ?? "";
_weekSubLabel = PersistentWeekSubLabel ?? "";
_loading = false;
}
var authState = await AuthStateProvider.GetAuthenticationStateAsync(); var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier); var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
if (claim == null) return; // Prerender-Pass Circuit noch nicht authentifiziert if (claim == null) return; // Prerender-Pass Circuit noch nicht authentifiziert
_userId = int.Parse(claim.Value); _userId = int.Parse(claim.Value);
_monday = GetMonday(DateOnly.FromDateTime(DateTime.Today)); _monday = GetMonday(DateOnly.FromDateTime(DateTime.Today));
_settings = await TrackerService.GetSettingsAsync(_userId);
var loadWeekTask = LoadWeek(); if (PersistentSettings == null || PersistentDays == null || PersistentTotalOvertime == null)
var overtimeTask = TrackerService.GetTotalOvertimeAsync(_userId, _settings); {
_settings = await TrackerService.GetSettingsAsync(_userId);
await Task.WhenAll(loadWeekTask, overtimeTask);
_totalOvertime = await overtimeTask; var loadWeekTask = LoadWeek();
var overtimeTask = TrackerService.GetTotalOvertimeAsync(_userId, _settings);
await Task.WhenAll(loadWeekTask, overtimeTask);
_totalOvertime = await overtimeTask;
PersistentSettings = _settings;
PersistentDays = _days;
PersistentTotalOvertime = _totalOvertime;
PersistentHolidays = _holidays;
PersistentHolidayYear = _holidayYear;
PersistentVacations = _vacations;
PersistentWeekLabel = _weekLabel;
PersistentWeekSubLabel = _weekSubLabel;
_loading = false;
}
try try
{ {
@@ -431,8 +482,6 @@ else
{ {
// Ignored during prerendering/SSR // Ignored during prerendering/SSR
} }
_loading = false;
} }
private async Task HandleOnboardingFinished() private async Task HandleOnboardingFinished()
@@ -693,7 +742,7 @@ else
} }
// ── ViewModels ──────────────────────────────────────────────── // ── ViewModels ────────────────────────────────────────────────
private sealed class DayVm public sealed class DayVm
{ {
public int Id { get; set; } public int Id { get; set; }
public int UserId { get; set; } public int UserId { get; set; }
@@ -743,7 +792,7 @@ else
}; };
} }
private sealed class BreakVm public sealed class BreakVm
{ {
public int Id { get; set; } public int Id { get; set; }
public TimeSpan? Start { get; set; } public TimeSpan? Start { get; set; }
+23 -18
View File
@@ -9,7 +9,7 @@
<PageTitle>@GetPageTitle()</PageTitle> <PageTitle>@GetPageTitle()</PageTitle>
@if (_tenantInfo == null) @if (TenantInfo == null)
{ {
@* Loading indicator while resolving Tenant *@ @* Loading indicator while resolving Tenant *@
<MudContainer MaxWidth="MaxWidth.Small" Class="mt-16 text-center"> <MudContainer MaxWidth="MaxWidth.Small" Class="mt-16 text-center">
@@ -17,7 +17,7 @@
<MudText Class="mt-4" Color="Color.Secondary">Lade Timetracker…</MudText> <MudText Class="mt-4" Color="Color.Secondary">Lade Timetracker…</MudText>
</MudContainer> </MudContainer>
} }
else if (_tenantInfo.IsTenant && !_tenantInfo.IsApproved) else if (TenantInfo.IsTenant && !TenantInfo.IsApproved)
{ {
@* Pending Approval Screen *@ @* Pending Approval Screen *@
<MudContainer MaxWidth="MaxWidth.Small" Class="mt-16"> <MudContainer MaxWidth="MaxWidth.Small" Class="mt-16">
@@ -34,7 +34,7 @@ else if (_tenantInfo.IsTenant && !_tenantInfo.IsApproved)
Class="mb-4" /> Class="mb-4" />
<MudText Typo="Typo.h5" Style="font-weight: 700;" Class="mb-2">Freischaltung ausstehend</MudText> <MudText Typo="Typo.h5" Style="font-weight: 700;" Class="mb-2">Freischaltung ausstehend</MudText>
<MudText Typo="Typo.body1" Color="Color.Secondary" Class="mb-6"> <MudText Typo="Typo.body1" Color="Color.Secondary" Class="mb-6">
Die Firma <strong>@_tenantInfo.Name</strong> wurde erfolgreich registriert. Die Firma <strong>@TenantInfo.Name</strong> wurde erfolgreich registriert.
Der Zugang wird derzeit von unserem SuperAdmin geprüft und in Kürze freigeschaltet. Der Zugang wird derzeit von unserem SuperAdmin geprüft und in Kürze freigeschaltet.
</MudText> </MudText>
<MudDivider Class="my-4" /> <MudDivider Class="my-4" />
@@ -64,9 +64,9 @@ else
<MudIcon Icon="@Icons.Material.Filled.AccessTime" <MudIcon Icon="@Icons.Material.Filled.AccessTime"
Style="font-size:4rem; color:#0EA5E9" /> Style="font-size:4rem; color:#0EA5E9" />
<MudText Typo="Typo.h4" Style="font-weight:700; color:#0EA5E9"> <MudText Typo="Typo.h4" Style="font-weight:700; color:#0EA5E9">
@(_tenantInfo.IsTenant ? _tenantInfo.Name : "Timetracker SaaS") @(TenantInfo.IsTenant ? TenantInfo.Name : "Timetracker SaaS")
</MudText> </MudText>
@if (!_tenantInfo.IsTenant) @if (!TenantInfo.IsTenant)
{ {
<MudText Typo="Typo.caption" Color="Color.Secondary">Zentrales Portal</MudText> <MudText Typo="Typo.caption" Color="Color.Secondary">Zentrales Portal</MudText>
} }
@@ -168,7 +168,7 @@ else
} }
else if (_activeTab == 1) else if (_activeTab == 1)
{ {
@if (_tenantInfo.IsTenant) @if (TenantInfo.IsTenant)
{ {
@* ── Employee Register Form ── *@ @* ── Employee Register Form ── *@
<MudStack Spacing="3"> <MudStack Spacing="3">
@@ -359,7 +359,7 @@ else
</MudStack> </MudStack>
} }
@if (!_tenantInfo.IsTenant && _activeTab != 2) @if (!TenantInfo.IsTenant && _activeTab != 2)
{ {
<MudDivider Class="my-4" /> <MudDivider Class="my-4" />
<MudStack AlignItems="AlignItems.Center"> <MudStack AlignItems="AlignItems.Center">
@@ -378,7 +378,9 @@ else
} }
@code { @code {
private TenantInfo? _tenantInfo; [PersistentState(AllowUpdates = true)]
public TenantInfo? TenantInfo { get; set; }
private int _activeTab = 0; private int _activeTab = 0;
private string? _error; private string? _error;
private bool _loading; private bool _loading;
@@ -399,15 +401,18 @@ else
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
try if (TenantInfo is null)
{ {
_tenantInfo = await Http.GetFromJsonAsync<TenantInfo>("api/tenant/current"); try
} {
catch (Exception ex) TenantInfo = await Http.GetFromJsonAsync<TenantInfo>("api/tenant/current");
{ }
Console.WriteLine($"Fehler beim Abrufen der TenantInfo: {ex.Message}"); catch (Exception ex)
// Fallback {
_tenantInfo = new TenantInfo { IsTenant = false }; Console.WriteLine($"Fehler beim Abrufen der TenantInfo: {ex.Message}");
// Fallback
TenantInfo = new TenantInfo { IsTenant = false };
}
} }
} }
@@ -430,8 +435,8 @@ else
private string GetPageTitle() private string GetPageTitle()
{ {
if (_tenantInfo == null) return "Lade…"; if (TenantInfo == null) return "Lade…";
return _tenantInfo.IsTenant ? $"Anmelden bei {_tenantInfo.Name} Timetracker" : "Anmelden Timetracker SaaS"; return TenantInfo.IsTenant ? $"Anmelden bei {TenantInfo.Name} Timetracker" : "Anmelden Timetracker SaaS";
} }
private string GetDomainSuffix() private string GetDomainSuffix()
@@ -33,6 +33,10 @@ else
</MudText> </MudText>
</MudStack> </MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0"> <MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
<MudTooltip Text="Stundenzettel (PDF) herunterladen">
<MudIconButton Icon="@Icons.Material.Filled.PictureAsPdf"
Style="color: var(--mud-palette-primary-text)" OnClick="ExportMonthPdf" />
</MudTooltip>
<MudTooltip Text="Monat als Excel exportieren"> <MudTooltip Text="Monat als Excel exportieren">
<MudIconButton Icon="@Icons.Material.Filled.Download" <MudIconButton Icon="@Icons.Material.Filled.Download"
Style="color: var(--mud-palette-primary-text)" OnClick="ExportMonthExcel" /> Style="color: var(--mud-palette-primary-text)" OnClick="ExportMonthExcel" />
@@ -155,6 +159,33 @@ else
@code { @code {
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE"); private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
[PersistentState(AllowUpdates = true)]
public AppSettings? PersistentSettings { get; set; }
[PersistentState(AllowUpdates = true)]
public List<MonthDayVm>? PersistentDays { get; set; }
[PersistentState(AllowUpdates = true)]
public string? PersistentSubLabel { get; set; }
[PersistentState(AllowUpdates = true)]
public TimeSpan? PersistentMonthNet { get; set; }
[PersistentState(AllowUpdates = true)]
public TimeSpan? PersistentMonthOvertime { get; set; }
[PersistentState(AllowUpdates = true)]
public int? PersistentRecordedWorkDays { get; set; }
[PersistentState(AllowUpdates = true)]
public int? PersistentTotalWorkDays { get; set; }
[PersistentState(AllowUpdates = true)]
public int? PersistentVacationCount { get; set; }
[PersistentState(AllowUpdates = true)]
public int? PersistentHolidayCount { get; set; }
private bool _loading = true; private bool _loading = true;
private int _userId; private int _userId;
private int _year = DateTime.Today.Year; private int _year = DateTime.Today.Year;
@@ -174,13 +205,41 @@ else
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
if (PersistentSettings != null && PersistentDays != null)
{
_settings = PersistentSettings;
_days = PersistentDays;
_subLabel = PersistentSubLabel ?? "";
_monthNet = PersistentMonthNet ?? TimeSpan.Zero;
_monthOvertime = PersistentMonthOvertime ?? TimeSpan.Zero;
_recordedWorkDays = PersistentRecordedWorkDays ?? 0;
_totalWorkDays = PersistentTotalWorkDays ?? 0;
_vacationCount = PersistentVacationCount ?? 0;
_holidayCount = PersistentHolidayCount ?? 0;
_loading = false;
}
var authState = await AuthStateProvider.GetAuthenticationStateAsync(); var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier); var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
if (claim == null) return; if (claim == null) return;
_userId = int.Parse(claim.Value); _userId = int.Parse(claim.Value);
_settings = await TrackerService.GetSettingsAsync(_userId);
await LoadMonth(); if (PersistentSettings == null || PersistentDays == null)
_loading = false; {
_settings = await TrackerService.GetSettingsAsync(_userId);
await LoadMonth();
PersistentSettings = _settings;
PersistentDays = _days;
PersistentSubLabel = _subLabel;
PersistentMonthNet = _monthNet;
PersistentMonthOvertime = _monthOvertime;
PersistentRecordedWorkDays = _recordedWorkDays;
PersistentTotalWorkDays = _totalWorkDays;
PersistentVacationCount = _vacationCount;
PersistentHolidayCount = _holidayCount;
_loading = false;
}
} }
private async Task LoadMonth() private async Task LoadMonth()
@@ -278,6 +337,11 @@ else
NavigationManager.NavigateTo($"api/tracker/export/month?year={_year}&month={_month}", forceLoad: true); NavigationManager.NavigateTo($"api/tracker/export/month?year={_year}&month={_month}", forceLoad: true);
} }
private void ExportMonthPdf()
{
NavigationManager.NavigateTo($"api/tracker/export/month/pdf?userId={_userId}&year={_year}&month={_month}", forceLoad: true);
}
private static string FormatTs(TimeSpan ts, bool sign = false) private static string FormatTs(TimeSpan ts, bool sign = false)
{ {
var neg = ts < TimeSpan.Zero; var neg = ts < TimeSpan.Zero;
@@ -317,7 +381,7 @@ else
else Chip("Ausstehend", "#CFD8DC"); else Chip("Ausstehend", "#CFD8DC");
}; };
private sealed class MonthDayVm public sealed class MonthDayVm
{ {
public DateOnly Date { get; set; } public DateOnly Date { get; set; }
public TimeOnly? StartTime { get; set; } public TimeOnly? StartTime { get; set; }
@@ -163,6 +163,21 @@ else
} }
@code { @code {
[PersistentState(AllowUpdates = true)]
public AppSettings? PersistentSettings { get; set; }
[PersistentState(AllowUpdates = true)]
public string? PersistentUsername { get; set; }
[PersistentState(AllowUpdates = true)]
public string? PersistentNewUsername { get; set; }
[PersistentState(AllowUpdates = true)]
public bool? PersistentIsAdmin { get; set; }
[PersistentState(AllowUpdates = true)]
public TimeSpan? PersistentOvertime { get; set; }
private bool _loading = true; private bool _loading = true;
private int _userId; private int _userId;
private string _username = ""; private string _username = "";
@@ -184,18 +199,38 @@ else
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
if (PersistentSettings != null)
{
_settings = PersistentSettings;
_username = PersistentUsername ?? "";
_newUsername = PersistentNewUsername ?? "";
_isAdmin = PersistentIsAdmin ?? false;
_overtime = PersistentOvertime ?? TimeSpan.Zero;
_loading = false;
}
var authState = await AuthStateProvider.GetAuthenticationStateAsync(); var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier); var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
if (claim == null) return; if (claim == null) return;
_userId = int.Parse(claim.Value); _userId = int.Parse(claim.Value);
_username = authState.User.Identity?.Name ?? "";
_newUsername = _username; if (PersistentSettings == null)
_isAdmin = _username.Equals("marc", StringComparison.OrdinalIgnoreCase); {
_username = authState.User.Identity?.Name ?? "";
_newUsername = _username;
_isAdmin = _username.Equals("marc", StringComparison.OrdinalIgnoreCase);
_settings = await TrackerService.GetSettingsAsync(_userId); _settings = await TrackerService.GetSettingsAsync(_userId);
_overtime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings); _overtime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings);
_loading = false;
PersistentSettings = _settings;
PersistentUsername = _username;
PersistentNewUsername = _newUsername;
PersistentIsAdmin = _isAdmin;
PersistentOvertime = _overtime;
_loading = false;
}
} }
private string GetInitials(string? name) private string GetInitials(string? name)
@@ -511,6 +511,27 @@ else
@code { @code {
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE"); private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
[PersistentState(AllowUpdates = true)]
public AppSettings? PersistentSettings { get; set; }
[PersistentState(AllowUpdates = true)]
public TenantInfo? PersistentTenantInfo { get; set; }
[PersistentState(AllowUpdates = true)]
public List<VacationDay>? PersistentVacationDays { get; set; }
[PersistentState(AllowUpdates = true)]
public List<PublicHoliday>? PersistentHolHolidays { get; set; }
[PersistentState(AllowUpdates = true)]
public string? PersistentTenantName { get; set; }
[PersistentState(AllowUpdates = true)]
public string? PersistentTenantPrimaryColor { get; set; }
[PersistentState(AllowUpdates = true)]
public string? PersistentTenantSecondaryColor { get; set; }
private AppSettings? _settings; private AppSettings? _settings;
private int _userId; private int _userId;
private bool _isTenantAdmin; private bool _isTenantAdmin;
@@ -571,36 +592,63 @@ else
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
if (PersistentSettings != null)
{
_settings = PersistentSettings;
_tenantInfo = PersistentTenantInfo;
_vacationDays = PersistentVacationDays ?? [];
_holHolidays = PersistentHolHolidays ?? [];
_tenantName = PersistentTenantName ?? "";
_tenantPrimaryColor = PersistentTenantPrimaryColor ?? "#0EA5E9";
_tenantSecondaryColor = PersistentTenantSecondaryColor ?? "#0EA5E9";
}
var authState = await AuthStateProvider.GetAuthenticationStateAsync(); var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier); var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
if (claim == null) return; if (claim == null) return;
_userId = int.Parse(claim.Value); _userId = int.Parse(claim.Value);
_settings = await TrackerService.GetSettingsAsync(_userId);
_isTenantAdmin = authState.User.IsInRole("TenantAdmin") || authState.User.HasClaim("IsTenantAdmin", "true"); if (PersistentSettings != null)
if (_isTenantAdmin)
{ {
try _isTenantAdmin = authState.User.IsInRole("TenantAdmin") || authState.User.HasClaim("IsTenantAdmin", "true");
}
else
{
_isTenantAdmin = authState.User.IsInRole("TenantAdmin") || authState.User.HasClaim("IsTenantAdmin", "true");
_settings = await TrackerService.GetSettingsAsync(_userId);
if (_isTenantAdmin)
{ {
_tenantInfo = await Http.GetFromJsonAsync<TenantInfo>("api/tenant/current"); try
if (_tenantInfo != null)
{ {
_tenantName = _tenantInfo.Name ?? ""; _tenantInfo = await Http.GetFromJsonAsync<TenantInfo>("api/tenant/current");
_tenantPrimaryColor = _tenantInfo.PrimaryColor ?? "#0EA5E9"; if (_tenantInfo != null)
_tenantSecondaryColor = _tenantInfo.SecondaryColor ?? "#0EA5E9"; {
_tenantName = _tenantInfo.Name ?? "";
_tenantPrimaryColor = _tenantInfo.PrimaryColor ?? "#0EA5E9";
_tenantSecondaryColor = _tenantInfo.SecondaryColor ?? "#0EA5E9";
}
}
catch (Exception ex)
{
Snackbar.Add($"Fehler beim Laden der Firmen-Einstellungen: {ex.Message}", Severity.Error);
} }
} }
catch (Exception ex)
{ var loadVacationsTask = LoadVacations();
Snackbar.Add($"Fehler beim Laden der Firmen-Einstellungen: {ex.Message}", Severity.Error); var loadHolidaysTask = HolidayService.GetHolidaysAsync(_holYear, _settings.GermanState);
}
await Task.WhenAll(loadVacationsTask, loadHolidaysTask);
_holHolidays = await loadHolidaysTask;
PersistentSettings = _settings;
PersistentTenantInfo = _tenantInfo;
PersistentVacationDays = _vacationDays;
PersistentHolHolidays = _holHolidays;
PersistentTenantName = _tenantName;
PersistentTenantPrimaryColor = _tenantPrimaryColor;
PersistentTenantSecondaryColor = _tenantSecondaryColor;
} }
var loadVacationsTask = LoadVacations();
var loadHolidaysTask = HolidayService.GetHolidaysAsync(_holYear, _settings.GermanState);
await Task.WhenAll(loadVacationsTask, loadHolidaysTask);
_holHolidays = await loadHolidaysTask;
} }
private async Task LoadVacations() private async Task LoadVacations()
+106 -63
View File
@@ -324,6 +324,27 @@ else
} }
@code { @code {
[PersistentState(AllowUpdates = true)]
public AppSettings? PersistentSettings { get; set; }
[PersistentState(AllowUpdates = true)]
public List<DayStat>? PersistentDays { get; set; }
[PersistentState(AllowUpdates = true)]
public TimeSpan? PersistentTotalOvertime { get; set; }
[PersistentState(AllowUpdates = true)]
public double? PersistentWeekWorkedHours { get; set; }
[PersistentState(AllowUpdates = true)]
public double? PersistentWeekTargetHours { get; set; }
[PersistentState(AllowUpdates = true)]
public double? PersistentWeekOvertimeHours { get; set; }
[PersistentState(AllowUpdates = true)]
public double? PersistentMaxHoursValue { get; set; }
private bool _loading = true; private bool _loading = true;
private int _userId; private int _userId;
private AppSettings _settings = new(); private AppSettings _settings = new();
@@ -349,84 +370,106 @@ else
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
if (PersistentSettings != null && PersistentDays != null)
{
_settings = PersistentSettings;
_days = PersistentDays;
_totalOvertime = PersistentTotalOvertime ?? TimeSpan.Zero;
_weekWorkedHours = PersistentWeekWorkedHours ?? 0.0;
_weekTargetHours = PersistentWeekTargetHours ?? 0.0;
_weekOvertimeHours = PersistentWeekOvertimeHours ?? 0.0;
_maxHoursValue = PersistentMaxHoursValue ?? 10.0;
_loading = false;
}
var authState = await AuthStateProvider.GetAuthenticationStateAsync(); var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier); var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
if (claim == null) return; if (claim == null) return;
_userId = int.Parse(claim.Value); _userId = int.Parse(claim.Value);
_settings = await TrackerService.GetSettingsAsync(_userId); if (PersistentSettings == null || PersistentDays == null)
var today = DateOnly.FromDateTime(DateTime.Today);
var monday = GetMonday(today);
var dbDays = await TrackerService.GetWeekAsync(_userId, monday);
_rawDbDaysCount = dbDays.Count;
_totalOvertime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings);
_weekTargetHours = 0;
_weekWorkedHours = 0;
_dbMatchStatus.Clear();
_days = Enumerable.Range(0, 7).Select(i =>
{ {
var date = monday.AddDays(i); _settings = await TrackerService.GetSettingsAsync(_userId);
bool isWorkDay = _settings.IsWorkDay(date.DayOfWeek);
double target = isWorkDay ? _settings.DailyTargetHours : 0.0;
if (isWorkDay) var today = DateOnly.FromDateTime(DateTime.Today);
{ var monday = GetMonday(today);
_weekTargetHours += _settings.DailyTargetHours;
} var dbDays = await TrackerService.GetWeekAsync(_userId, monday);
_rawDbDaysCount = dbDays.Count;
_totalOvertime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings);
var wd = dbDays.FirstOrDefault(d => d.Date == date); _weekTargetHours = 0;
double worked = 0.0; _weekWorkedHours = 0;
_dbMatchStatus.Clear();
if (wd != null)
_days = Enumerable.Range(0, 7).Select(i =>
{ {
_dbMatchStatus[date] = $"Ja (Id={wd.Id}, Start={wd.StartTime}, Ende={wd.EndTime})"; var date = monday.AddDays(i);
bool isWorkDay = _settings.IsWorkDay(date.DayOfWeek);
double target = isWorkDay ? _settings.DailyTargetHours : 0.0;
if (wd.StartTime != null && wd.EndTime != null) if (isWorkDay)
{ {
var gross = wd.EndTime.Value.ToTimeSpan() - wd.StartTime.Value.ToTimeSpan(); _weekTargetHours += _settings.DailyTargetHours;
if (gross > TimeSpan.Zero) }
var wd = dbDays.FirstOrDefault(d => d.Date == date);
double worked = 0.0;
if (wd != null)
{
_dbMatchStatus[date] = $"Ja (Id={wd.Id}, Start={wd.StartTime}, Ende={wd.EndTime})";
if (wd.StartTime != null && wd.EndTime != null)
{ {
var breakTotal = wd.Breaks var gross = wd.EndTime.Value.ToTimeSpan() - wd.StartTime.Value.ToTimeSpan();
.Where(b => b.StartTime.HasValue && b.EndTime.HasValue && b.EndTime > b.StartTime) if (gross > TimeSpan.Zero)
.Aggregate(TimeSpan.Zero, (s, b) => {
s + (b.EndTime!.Value.ToTimeSpan() - b.StartTime!.Value.ToTimeSpan())); var breakTotal = wd.Breaks
var minMinutes = gross.TotalHours >= 9.0 ? 45 : 30; .Where(b => b.StartTime.HasValue && b.EndTime.HasValue && b.EndTime > b.StartTime)
var minBreak = TimeSpan.FromMinutes(Math.Max(minMinutes, _settings.MinimumBreakMinutes)); .Aggregate(TimeSpan.Zero, (s, b) =>
var finalBreak = breakTotal > minBreak ? breakTotal : minBreak; s + (b.EndTime!.Value.ToTimeSpan() - b.StartTime!.Value.ToTimeSpan()));
worked = (gross - finalBreak).TotalHours; var minMinutes = gross.TotalHours >= 9.0 ? 45 : 30;
if (worked < 0) worked = 0.0; var minBreak = TimeSpan.FromMinutes(Math.Max(minMinutes, _settings.MinimumBreakMinutes));
var finalBreak = breakTotal > minBreak ? breakTotal : minBreak;
worked = (gross - finalBreak).TotalHours;
if (worked < 0) worked = 0.0;
}
} }
} }
} else
else {
{ _dbMatchStatus[date] = "Nein";
_dbMatchStatus[date] = "Nein"; }
}
_weekWorkedHours += worked;
return new DayStat
{
Date = date,
DayName = date.ToString("dddd", _deCulture),
DayShortName = date.ToString("ddd", _deCulture),
WorkedHours = worked,
TargetHours = target,
IsToday = date == today,
IsWorkDay = isWorkDay
};
}).ToList();
_weekOvertimeHours = _weekWorkedHours - _weekTargetHours;
_weekWorkedHours += worked; var maxWorked = _days.Max(d => d.WorkedHours);
_maxHoursValue = Math.Max(10.0, Math.Max(maxWorked, _settings.DailyTargetHours));
return new DayStat PersistentSettings = _settings;
{ PersistentDays = _days;
Date = date, PersistentTotalOvertime = _totalOvertime;
DayName = date.ToString("dddd", _deCulture), PersistentWeekWorkedHours = _weekWorkedHours;
DayShortName = date.ToString("ddd", _deCulture), PersistentWeekTargetHours = _weekTargetHours;
WorkedHours = worked, PersistentWeekOvertimeHours = _weekOvertimeHours;
TargetHours = target, PersistentMaxHoursValue = _maxHoursValue;
IsToday = date == today, _loading = false;
IsWorkDay = isWorkDay }
};
}).ToList();
_weekOvertimeHours = _weekWorkedHours - _weekTargetHours;
var maxWorked = _days.Max(d => d.WorkedHours);
_maxHoursValue = Math.Max(10.0, Math.Max(maxWorked, _settings.DailyTargetHours));
_loading = false;
} }
private static DateOnly GetMonday(DateOnly date) private static DateOnly GetMonday(DateOnly date)
@@ -468,7 +511,7 @@ else
NavigationManager.NavigateTo($"api/tracker/export/year?year={_exportYear}", forceLoad: true); NavigationManager.NavigateTo($"api/tracker/export/year?year={_exportYear}", forceLoad: true);
} }
private sealed class DayStat public sealed class DayStat
{ {
public DateOnly Date { get; set; } public DateOnly Date { get; set; }
public string DayName { get; set; } = ""; public string DayName { get; set; } = "";
@@ -167,12 +167,23 @@ else
} }
@code { @code {
[PersistentState(AllowUpdates = true)]
public List<TenantStats>? PersistentTenants { get; set; }
private bool _loading = true; private bool _loading = true;
private List<TenantStats> _tenants = []; private List<TenantStats> _tenants = [];
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
await LoadTenants(); if (PersistentTenants != null)
{
_tenants = PersistentTenants;
_loading = false;
}
else
{
await LoadTenants();
}
} }
private async Task LoadTenants() private async Task LoadTenants()
@@ -181,6 +192,7 @@ else
try try
{ {
_tenants = await Http.GetFromJsonAsync<List<TenantStats>>("api/superadmin/tenants") ?? []; _tenants = await Http.GetFromJsonAsync<List<TenantStats>>("api/superadmin/tenants") ?? [];
PersistentTenants = _tenants;
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -226,6 +226,27 @@ else
@code { @code {
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE"); private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
[PersistentState(AllowUpdates = true)]
public AppSettings? PersistentSettings { get; set; }
[PersistentState(AllowUpdates = true)]
public Dictionary<DateOnly, string>? PersistentHolidays { get; set; }
[PersistentState(AllowUpdates = true)]
public List<DateOnly>? PersistentVacationList { get; set; }
[PersistentState(AllowUpdates = true)]
public int? PersistentRemainingDays { get; set; }
[PersistentState(AllowUpdates = true)]
public List<Suggestion>? PersistentSuggestions { get; set; }
[PersistentState(AllowUpdates = true)]
public string? PersistentSubLabel { get; set; }
[PersistentState(AllowUpdates = true)]
public int? PersistentYear { get; set; }
private bool _loading = true; private bool _loading = true;
private int _year = DateTime.Today.Year; private int _year = DateTime.Today.Year;
private AppSettings _settings = new(); private AppSettings _settings = new();
@@ -238,13 +259,37 @@ else
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
if (PersistentSettings != null && PersistentSuggestions != null)
{
_settings = PersistentSettings;
_holidays = PersistentHolidays ?? [];
_vacationSet = (PersistentVacationList ?? []).ToHashSet();
_remainingDays = PersistentRemainingDays ?? 0;
_suggestions = PersistentSuggestions;
_subLabel = PersistentSubLabel ?? "";
_year = PersistentYear ?? DateTime.Today.Year;
_loading = false;
}
var authState = await AuthStateProvider.GetAuthenticationStateAsync(); var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier); var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
if (claim == null) return; if (claim == null) return;
_userId = int.Parse(claim.Value); _userId = int.Parse(claim.Value);
_settings = await TrackerService.GetSettingsAsync(_userId);
await LoadYear(); if (PersistentSettings == null || PersistentSuggestions == null)
_loading = false; {
_settings = await TrackerService.GetSettingsAsync(_userId);
await LoadYear();
PersistentSettings = _settings;
PersistentHolidays = _holidays;
PersistentVacationList = _vacationSet.ToList();
PersistentRemainingDays = _remainingDays;
PersistentSuggestions = _suggestions;
PersistentSubLabel = _subLabel;
PersistentYear = _year;
_loading = false;
}
} }
private async Task LoadYear() private async Task LoadYear()
@@ -283,7 +328,7 @@ else
// ── Algorithmus ────────────────────────────────────────────────────── // ── Algorithmus ──────────────────────────────────────────────────────
private enum DayKind { Free, WorkAvailable, WorkTaken } private enum DayKind { Free, WorkAvailable, WorkTaken }
private sealed record Suggestion( public sealed record Suggestion(
DateOnly SpanStart, DateOnly SpanStart,
DateOnly SpanEnd, DateOnly SpanEnd,
List<DateOnly> VacationDaysToTake, List<DateOnly> VacationDaysToTake,
@@ -70,4 +70,14 @@ public class ClientTimetrackerService : ITimetrackerService
{ {
return await _http.GetFromJsonAsync<List<WorkDay>>($"api/tracker/month?year={year}&month={month}") ?? []; return await _http.GetFromJsonAsync<List<WorkDay>>($"api/tracker/month?year={year}&month={month}") ?? [];
} }
public async Task<DashboardStatsVm> GetDashboardStatsAsync(int userId, int year, int month)
{
return await _http.GetFromJsonAsync<DashboardStatsVm>($"api/tracker/dashboard/stats?year={year}&month={month}") ?? new DashboardStatsVm();
}
public async Task<DashboardChartDataVm> GetDashboardChartDataAsync(int userId, int year)
{
return await _http.GetFromJsonAsync<DashboardChartDataVm>($"api/tracker/dashboard/chart?year={year}") ?? new DashboardChartDataVm();
}
} }
@@ -1,5 +1,6 @@
using System.Security.Claims; using System.Security.Claims;
using System.Net.Http.Json; using System.Net.Http.Json;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
using timetracker.Shared; using timetracker.Shared;
@@ -8,12 +9,14 @@ namespace timetracker.Client.Services;
public class HostAuthenticationStateProvider : AuthenticationStateProvider public class HostAuthenticationStateProvider : AuthenticationStateProvider
{ {
private readonly HttpClient _http; private readonly HttpClient _http;
private readonly PersistentComponentState _state;
private static readonly ClaimsPrincipal Anonymous = new(new ClaimsIdentity()); private static readonly ClaimsPrincipal Anonymous = new(new ClaimsIdentity());
private ClaimsPrincipal? _currentUser; private ClaimsPrincipal? _currentUser;
public HostAuthenticationStateProvider(HttpClient http) public HostAuthenticationStateProvider(HttpClient http, PersistentComponentState state)
{ {
_http = http; _http = http;
_state = state;
} }
public override async Task<AuthenticationState> GetAuthenticationStateAsync() public override async Task<AuthenticationState> GetAuthenticationStateAsync()
@@ -23,20 +26,40 @@ public class HostAuthenticationStateProvider : AuthenticationStateProvider
return new AuthenticationState(_currentUser); return new AuthenticationState(_currentUser);
} }
if (_state.TryTakeFromJson<UserInfo>("UserInfoState", out var userInfo) && userInfo != null)
{
if (!userInfo.IsAuthenticated)
{
_currentUser = Anonymous;
return new AuthenticationState(_currentUser);
}
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, userInfo.Id.ToString()),
new Claim(ClaimTypes.Name, userInfo.Username),
new Claim("IsTenantAdmin", userInfo.IsTenantAdmin ? "true" : "false"),
new Claim(ClaimTypes.Role, userInfo.IsTenantAdmin ? "TenantAdmin" : "Employee")
};
var identity = new ClaimsIdentity(claims, "Cookie");
_currentUser = new ClaimsPrincipal(identity);
return new AuthenticationState(_currentUser);
}
try try
{ {
var response = await _http.GetAsync("api/auth/me"); var response = await _http.GetAsync("api/auth/me");
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
var userInfo = await response.Content.ReadFromJsonAsync<UserInfo>(); var apiUserInfo = await response.Content.ReadFromJsonAsync<UserInfo>();
if (userInfo != null) if (apiUserInfo != null)
{ {
var claims = new[] var claims = new[]
{ {
new Claim(ClaimTypes.NameIdentifier, userInfo.Id.ToString()), new Claim(ClaimTypes.NameIdentifier, apiUserInfo.Id.ToString()),
new Claim(ClaimTypes.Name, userInfo.Username), new Claim(ClaimTypes.Name, apiUserInfo.Username),
new Claim("IsTenantAdmin", userInfo.IsTenantAdmin ? "true" : "false"), new Claim("IsTenantAdmin", apiUserInfo.IsTenantAdmin ? "true" : "false"),
new Claim(ClaimTypes.Role, userInfo.IsTenantAdmin ? "TenantAdmin" : "Employee") new Claim(ClaimTypes.Role, apiUserInfo.IsTenantAdmin ? "TenantAdmin" : "Employee")
}; };
var identity = new ClaimsIdentity(claims, "Cookie"); var identity = new ClaimsIdentity(claims, "Cookie");
_currentUser = new ClaimsPrincipal(identity); _currentUser = new ClaimsPrincipal(identity);
+1
View File
@@ -27,6 +27,7 @@
<body> <body>
<Routes /> <Routes />
<persist-component-state />
<script src="@Assets["_framework/blazor.web.js"]"></script> <script src="@Assets["_framework/blazor.web.js"]"></script>
<script src="_content/MudBlazor/MudBlazor.min.js"></script> <script src="_content/MudBlazor/MudBlazor.min.js"></script>
<script> <script>
+17 -1
View File
@@ -14,6 +14,8 @@ public class AuthService(
UserNotificationService notifier, UserNotificationService notifier,
ITenantProvider tenantProvider) : IAuthService ITenantProvider tenantProvider) : IAuthService
{ {
private const string ReservedSuperAdminUsername = "marc";
public async Task<User?> LoginAsync(string username, string password) public async Task<User?> LoginAsync(string username, string password)
{ {
await using var db = await factory.CreateDbContextAsync(); await using var db = await factory.CreateDbContextAsync();
@@ -58,6 +60,9 @@ public class AuthService(
var user = await db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == userId); var user = await db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == userId);
if (user == null) return "Benutzer nicht gefunden."; if (user == null) return "Benutzer nicht gefunden.";
if (!CanUseReservedUsername(user.TenantId, newUsername, allowExistingReservedUser: user.TenantId == null && user.Username.Equals(ReservedSuperAdminUsername, StringComparison.OrdinalIgnoreCase)))
return "Dieser Benutzername ist reserviert.";
// Check if name is taken within the same tenant context // Check if name is taken within the same tenant context
var isTaken = await db.Users.AnyAsync(u => u.Username == newUsername && u.Id != userId && u.TenantId == user.TenantId); var isTaken = await db.Users.AnyAsync(u => u.Username == newUsername && u.Id != userId && u.TenantId == user.TenantId);
if (isTaken) if (isTaken)
@@ -97,7 +102,7 @@ public class AuthService(
return (null, "Passwort muss mindestens 6 Zeichen lang sein."); return (null, "Passwort muss mindestens 6 Zeichen lang sein.");
var tenantId = tenantProvider.TenantId; var tenantId = tenantProvider.TenantId;
if (tenantId == null && username.Equals("marc", StringComparison.OrdinalIgnoreCase)) if (!CanUseReservedUsername(tenantId, username))
{ {
return (null, "Dieser Benutzername ist reserviert."); return (null, "Dieser Benutzername ist reserviert.");
} }
@@ -132,6 +137,9 @@ public class AuthService(
if (string.IsNullOrWhiteSpace(req.AdminPassword) || req.AdminPassword.Length < 6) if (string.IsNullOrWhiteSpace(req.AdminPassword) || req.AdminPassword.Length < 6)
return (null, "Passwort muss mindestens 6 Zeichen lang sein."); return (null, "Passwort muss mindestens 6 Zeichen lang sein.");
if (!CanUseReservedUsername(tenantId: 0, req.AdminUsername))
return (null, "Dieser Benutzername ist reserviert.");
var subdomain = req.Subdomain.ToLower().Trim(); var subdomain = req.Subdomain.ToLower().Trim();
if (!subdomain.All(c => char.IsLetterOrDigit(c) || c == '-')) if (!subdomain.All(c => char.IsLetterOrDigit(c) || c == '-'))
{ {
@@ -258,6 +266,14 @@ public class AuthService(
private static bool VerifyPassword(string password, string hash, string salt) private static bool VerifyPassword(string password, string hash, string salt)
=> ComputeHash(password, salt) == hash; => ComputeHash(password, salt) == hash;
private static bool CanUseReservedUsername(int? tenantId, string username, bool allowExistingReservedUser = false)
{
if (!username.Equals(ReservedSuperAdminUsername, StringComparison.OrdinalIgnoreCase))
return true;
return allowExistingReservedUser || tenantId == null;
}
private static string ComputeHash(string password, string salt) private static string ComputeHash(string password, string salt)
{ {
var hash = Rfc2898DeriveBytes.Pbkdf2( var hash = Rfc2898DeriveBytes.Pbkdf2(
@@ -1,4 +1,4 @@
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
@@ -13,7 +13,6 @@ namespace timetracker.Data.Migrations
migrationBuilder.AddColumn<bool>( migrationBuilder.AddColumn<bool>(
name: "IsTenantAdmin", name: "IsTenantAdmin",
table: "Users", table: "Users",
type: "INTEGER",
nullable: false, nullable: false,
defaultValue: false); defaultValue: false);
@@ -199,4 +199,144 @@ public class TimetrackerService(IDbContextFactory<TimetrackerDbContext> factory)
.OrderBy(w => w.Date) .OrderBy(w => w.Date)
.ToListAsync(); .ToListAsync();
} }
// ── Dashboard ─────────────────────────────────────────────────────────
public async Task<DashboardStatsVm> GetDashboardStatsAsync(int userId, int year, int month)
{
await using var db = await factory.CreateDbContextAsync();
var startDate = new DateOnly(year, month, 1);
var endDate = startDate.AddMonths(1).AddDays(-1);
var workDays = await db.WorkDays
.Include(w => w.Breaks)
.Where(w => w.UserId == userId && w.Date >= startDate && w.Date <= endDate)
.ToListAsync();
var stats = new DashboardStatsVm();
if (workDays.Any(w => w.StartTime.HasValue))
{
var earliest = workDays.Where(w => w.StartTime.HasValue).Min(w => w.StartTime!.Value);
stats.EarliestStart = earliest.ToTimeSpan();
}
if (workDays.Any(w => w.EndTime.HasValue))
{
var latest = workDays.Where(w => w.EndTime.HasValue).Max(w => w.EndTime!.Value);
stats.LatestEnd = latest.ToTimeSpan();
}
var daysWithBreaks = workDays.Where(w => w.Breaks.Any(b => b.StartTime.HasValue && b.EndTime.HasValue)).ToList();
if (daysWithBreaks.Any())
{
var totalBreakTicks = daysWithBreaks.Sum(w =>
w.Breaks.Where(b => b.StartTime.HasValue && b.EndTime.HasValue)
.Sum(b => (b.EndTime!.Value - b.StartTime!.Value).Ticks));
stats.AverageBreak = TimeSpan.FromTicks(totalBreakTicks / daysWithBreaks.Count);
}
var allUserDays = await db.WorkDays
.Where(w => w.UserId == userId && w.StartTime.HasValue && w.EndTime.HasValue)
.OrderBy(w => w.Date)
.Select(w => w.Date)
.ToListAsync();
if (allUserDays.Any())
{
int currentStreak = 1;
int maxStreak = 1;
int tempStreak = 1;
for (int i = 1; i < allUserDays.Count; i++)
{
if (allUserDays[i].DayNumber == allUserDays[i - 1].DayNumber + 1)
{
tempStreak++;
}
else if (allUserDays[i].DayNumber > allUserDays[i - 1].DayNumber + 1)
{
if (tempStreak > maxStreak) maxStreak = tempStreak;
tempStreak = 1;
}
}
if (tempStreak > maxStreak) maxStreak = tempStreak;
stats.LongestStreak = maxStreak;
var today = DateOnly.FromDateTime(DateTime.Today);
var yesterday = today.AddDays(-1);
if (allUserDays.Last() == today || allUserDays.Last() == yesterday)
{
stats.CurrentStreak = tempStreak;
}
else
{
stats.CurrentStreak = 0;
}
}
return stats;
}
public async Task<DashboardChartDataVm> GetDashboardChartDataAsync(int userId, int year)
{
await using var db = await factory.CreateDbContextAsync();
var chartData = new DashboardChartDataVm();
var settings = await GetSettingsAsync(userId);
var workDaysThisYear = await db.WorkDays
.Include(w => w.Breaks)
.Where(w => w.UserId == userId && w.Date.Year == year)
.ToListAsync();
var vacationsThisYear = await db.VacationDays
.Where(v => v.UserId == userId && v.Date.Year == year)
.ToListAsync();
var holidaysThisYear = await db.PublicHolidays
.Where(h => h.Date.Year == year && h.Counties.Contains(settings.GermanState))
.ToListAsync();
chartData.WorkDaysCount = workDaysThisYear.Count(w => w.StartTime.HasValue);
chartData.VacationDaysCount = vacationsThisYear.Count;
chartData.PublicHolidaysCount = holidaysThisYear.Count(h => settings.IsWorkDay(h.Date.DayOfWeek));
for (int month = 1; month <= 12; month++)
{
var daysInMonth = workDaysThisYear.Where(w => w.Date.Month == month).ToList();
if (!daysInMonth.Any(d => d.StartTime.HasValue)) continue;
double monthOvertime = 0;
foreach (var day in daysInMonth)
{
if (!day.StartTime.HasValue || !day.EndTime.HasValue) continue;
var gross = day.EndTime.Value - day.StartTime.Value;
var breakTicks = day.Breaks
.Where(b => b.StartTime.HasValue && b.EndTime.HasValue)
.Sum(b => (b.EndTime!.Value - b.StartTime!.Value).Ticks);
var enteredBreak = TimeSpan.FromTicks(breakTicks);
int minMinutes = gross.TotalHours >= 9.0 ? 45 : 30;
int finalMin = Math.Max(minMinutes, settings.MinimumBreakMinutes);
var minBreak = TimeSpan.FromMinutes(finalMin);
var effectiveBreak = enteredBreak > minBreak ? enteredBreak : minBreak;
var net = gross - effectiveBreak;
var target = TimeSpan.FromHours(settings.DailyTargetHours);
var isHoliday = holidaysThisYear.Any(h => h.Date == day.Date);
var isVacation = vacationsThisYear.Any(v => v.Date == day.Date);
var isWorkDay = settings.IsWorkDay(day.Date.DayOfWeek);
if (!isWorkDay || isHoliday || isVacation) target = TimeSpan.Zero;
monthOvertime += (net - target).TotalHours;
}
var monthName = new DateTime(year, month, 1).ToString("MMM", new System.Globalization.CultureInfo("de-DE"));
chartData.MonthlyOvertime.Add(new MonthlyOvertimeVm { MonthName = monthName, OvertimeHours = monthOvertime });
}
return chartData;
}
} }
+3
View File
@@ -27,15 +27,18 @@ COPY --from=build /app/publish .
# Directory for SQLite database # Directory for SQLite database
RUN mkdir -p /data RUN mkdir -p /data
RUN mkdir -p /app/logs
ENV ASPNETCORE_HTTP_PORTS=8080 ENV ASPNETCORE_HTTP_PORTS=8080
ENV ASPNETCORE_ENVIRONMENT=Production ENV ASPNETCORE_ENVIRONMENT=Production
ENV ASPNETCORE_FORWARDEDHEADERS_ENABLED=true ENV ASPNETCORE_FORWARDEDHEADERS_ENABLED=true
ENV TIMETRACKER_DB_PATH=/data/timetracker.db ENV TIMETRACKER_DB_PATH=/data/timetracker.db
ENV TIMETRACKER_LOG_DIR=/app/logs
ENV EnableHttpsRedirect=false ENV EnableHttpsRedirect=false
EXPOSE 8080 EXPOSE 8080
VOLUME ["/data"] VOLUME ["/data"]
VOLUME ["/app/logs"]
ENTRYPOINT ["dotnet", "timetracker.Server.dll"] ENTRYPOINT ["dotnet", "timetracker.Server.dll"]
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,59 @@
using System.Diagnostics;
using System.Security.Claims;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server;
using Microsoft.AspNetCore.Components.Web;
using timetracker.Shared;
namespace timetracker.Server;
public class PersistingServerAuthenticationStateProvider : ServerAuthenticationStateProvider, IDisposable
{
private readonly PersistentComponentState _state;
private readonly PersistingComponentStateSubscription _subscription;
public PersistingServerAuthenticationStateProvider(PersistentComponentState state)
{
_state = state;
_subscription = state.RegisterOnPersisting(PersistAuthenticationState);
}
private async Task PersistAuthenticationState()
{
var authState = await GetAuthenticationStateAsync();
var user = authState.User;
if (user.Identity?.IsAuthenticated == true)
{
var idClaim = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var name = user.Identity.Name;
var isAdminClaim = user.FindFirst("IsTenantAdmin")?.Value;
if (idClaim != null && name != null)
{
var userInfo = new UserInfo
{
Id = int.Parse(idClaim),
Username = name,
IsTenantAdmin = isAdminClaim == "true",
IsAuthenticated = true
};
_state.PersistAsJson("UserInfoState", userInfo);
}
}
else
{
var userInfo = new UserInfo
{
IsAuthenticated = false
};
_state.PersistAsJson("UserInfoState", userInfo);
}
}
public void Dispose()
{
_subscription.Dispose();
}
}
+299 -19
View File
@@ -1,16 +1,41 @@
using System.Security.Claims; using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Components.Authorization;
using MudBlazor.Services; using MudBlazor.Services;
using Serilog;
using timetracker.Server;
using timetracker.Server.Components; using timetracker.Server.Components;
using timetracker.Data; using timetracker.Data;
using timetracker.Shared; using timetracker.Shared;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
const string SuperAdminUsername = "marc";
const string SuperAdminPassword = "123456";
var logDirectory = Environment.GetEnvironmentVariable("TIMETRACKER_LOG_DIR")
?? Path.Combine(builder.Environment.ContentRootPath, "logs");
Directory.CreateDirectory(logDirectory);
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(builder.Configuration)
.Enrich.FromLogContext()
.WriteTo.Console()
.WriteTo.File(
Path.Combine(logDirectory, "timetracker-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 14,
shared: true,
flushToDiskInterval: TimeSpan.FromSeconds(1))
.CreateLogger();
builder.Host.UseSerilog();
// Add Authentication // Add Authentication
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
@@ -45,6 +70,7 @@ builder.Services.AddAuthorization(options =>
}); });
builder.Services.AddCascadingAuthenticationState(); builder.Services.AddCascadingAuthenticationState();
builder.Services.AddScoped<AuthenticationStateProvider, PersistingServerAuthenticationStateProvider>();
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
builder.Services.AddTransient<ITenantProvider, TenantProvider>(); builder.Services.AddTransient<ITenantProvider, TenantProvider>();
@@ -57,6 +83,7 @@ builder.Services.AddSingleton<IUserNotificationService>(sp => sp.GetRequiredServ
// Register DB-backed services as the interfaces // Register DB-backed services as the interfaces
builder.Services.AddScoped<IAuthService, AuthService>(); builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<ITimetrackerService, TimetrackerService>(); builder.Services.AddScoped<ITimetrackerService, TimetrackerService>();
builder.Services.AddScoped<timetracker.Server.Services.PdfGeneratorService>();
builder.Services.AddScoped<IHolidayService, HolidayService>(); builder.Services.AddScoped<IHolidayService, HolidayService>();
// Add services to the container. // Add services to the container.
@@ -128,12 +155,13 @@ else
var app = builder.Build(); var app = builder.Build();
app.Logger.LogInformation("File logging active. Writing logs to {LogDirectory}", logDirectory);
// Migrate or Ensure Database Created // Migrate or Ensure Database Created
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())
{ {
var factory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<TimetrackerDbContext>>(); var factory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<TimetrackerDbContext>>();
await using var db = await factory.CreateDbContextAsync(); await using var db = await factory.CreateDbContextAsync();
if (dbProvider.Equals("PostgreSQL", StringComparison.OrdinalIgnoreCase)) if (dbProvider.Equals("PostgreSQL", StringComparison.OrdinalIgnoreCase))
{ {
try try
@@ -164,28 +192,52 @@ using (var scope = app.Services.CreateScope())
await cmdCreateHistory.ExecuteNonQueryAsync(); await cmdCreateHistory.ExecuteNonQueryAsync();
} }
// Check if the initial migration is already recorded // Seed the history table conditionally for each migration based on actual schema presence
using (var cmdCheckHistory = conn.CreateCommand()) using (var cmdSyncHistory = conn.CreateCommand())
{ {
cmdCheckHistory.CommandText = @"SELECT COUNT(*) FROM ""__EFMigrationsHistory"" WHERE ""MigrationId"" = '20260520133634_Initial';"; cmdSyncHistory.CommandText = @"
var historyCount = Convert.ToInt32(await cmdCheckHistory.ExecuteScalarAsync() ?? 0); INSERT INTO ""__EFMigrationsHistory"" (""MigrationId"", ""ProductVersion"")
SELECT '20260520133634_Initial', '10.0.9'
WHERE NOT EXISTS (SELECT 1 FROM ""__EFMigrationsHistory"" WHERE ""MigrationId"" = '20260520133634_Initial');
INSERT INTO ""__EFMigrationsHistory"" (""MigrationId"", ""ProductVersion"")
SELECT '20260520200000_AddPublicHolidays', '10.0.9'
WHERE NOT EXISTS (SELECT 1 FROM ""__EFMigrationsHistory"" WHERE ""MigrationId"" = '20260520200000_AddPublicHolidays');
if (historyCount == 0) INSERT INTO ""__EFMigrationsHistory"" (""MigrationId"", ""ProductVersion"")
{ SELECT '20260522081459_AddMultiUser', '10.0.9'
// Seed the history table with the migrations that were already created by EnsureCreated in older versions WHERE NOT EXISTS (SELECT 1 FROM ""__EFMigrationsHistory"" WHERE ""MigrationId"" = '20260522081459_AddMultiUser');
using (var cmdInsertHistory = conn.CreateCommand())
{ INSERT INTO ""__EFMigrationsHistory"" (""MigrationId"", ""ProductVersion"")
cmdInsertHistory.CommandText = @" SELECT '20260607213215_AddFlexTimeAndHolidayState', '10.0.9'
INSERT INTO ""__EFMigrationsHistory"" (""MigrationId"", ""ProductVersion"") VALUES WHERE NOT EXISTS (SELECT 1 FROM ""__EFMigrationsHistory"" WHERE ""MigrationId"" = '20260607213215_AddFlexTimeAndHolidayState');
('20260520133634_Initial', '10.0.9'),
('20260520200000_AddPublicHolidays', '10.0.9'), -- Seed AddMultiTenancy only if Tenants table exists
('20260522081459_AddMultiUser', '10.0.9'), INSERT INTO ""__EFMigrationsHistory"" (""MigrationId"", ""ProductVersion"")
('20260607213215_AddFlexTimeAndHolidayState', '10.0.9');"; SELECT '20260624204954_AddMultiTenancy', '10.0.9'
await cmdInsertHistory.ExecuteNonQueryAsync(); WHERE EXISTS (SELECT FROM pg_tables WHERE schemaname = 'public' AND tablename = 'Tenants')
} AND NOT EXISTS (SELECT 1 FROM ""__EFMigrationsHistory"" WHERE ""MigrationId"" = '20260624204954_AddMultiTenancy');
}
-- Seed AddTenantBranding only if PrimaryColor column exists
INSERT INTO ""__EFMigrationsHistory"" (""MigrationId"", ""ProductVersion"")
SELECT '20260624210436_AddTenantBranding', '10.0.9'
WHERE EXISTS (SELECT FROM information_schema.columns WHERE table_name = 'Tenants' AND column_name = 'PrimaryColor')
AND NOT EXISTS (SELECT 1 FROM ""__EFMigrationsHistory"" WHERE ""MigrationId"" = '20260624210436_AddTenantBranding');
-- Seed PendingChanges only if IsApproved column in VacationDays exists
INSERT INTO ""__EFMigrationsHistory"" (""MigrationId"", ""ProductVersion"")
SELECT '20260625184756_PendingChanges', '10.0.9'
WHERE EXISTS (SELECT FROM information_schema.columns WHERE table_name = 'VacationDays' AND column_name = 'IsApproved')
AND NOT EXISTS (SELECT 1 FROM ""__EFMigrationsHistory"" WHERE ""MigrationId"" = '20260625184756_PendingChanges');
";
await cmdSyncHistory.ExecuteNonQueryAsync();
} }
} }
else
{
// DB is empty, MigrateAsync will create everything. We don't need to patch types later if it was purely MigrateAsync...
// Wait, SQLite migrations might still create broken defaults on MigrateAsync. We keep EnsurePostgresIntegerPrimaryKeyDefaultAsync below.
}
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -195,6 +247,123 @@ using (var scope = app.Services.CreateScope())
} }
await db.Database.MigrateAsync(); await db.Database.MigrateAsync();
if (dbProvider.Equals("PostgreSQL", StringComparison.OrdinalIgnoreCase))
{
try
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
{
await conn.OpenAsync();
}
foreach (var tableName in new[] { "Users", "Tenants", "AppSettings", "WorkDays", "BreakEntries", "VacationDays", "PublicHolidays" })
{
await EnsurePostgresIntegerPrimaryKeyDefaultAsync(conn, tableName);
}
// Check if column IsTenantAdmin in Users table is of type integer
using (var cmdCheckType = conn.CreateCommand())
{
cmdCheckType.CommandText = @"
SELECT data_type
FROM information_schema.columns
WHERE table_name = 'Users' AND column_name = 'IsTenantAdmin';";
var dataType = await cmdCheckType.ExecuteScalarAsync() as string;
if (dataType != null && dataType.Equals("integer", StringComparison.OrdinalIgnoreCase))
{
// Drop legacy integer default before changing the column type.
using (var cmdAlterType = conn.CreateCommand())
{
cmdAlterType.CommandText = @"
ALTER TABLE ""Users""
ALTER COLUMN ""IsTenantAdmin"" DROP DEFAULT,
ALTER COLUMN ""IsTenantAdmin"" TYPE boolean
USING (""IsTenantAdmin"" <> 0),
ALTER COLUMN ""IsTenantAdmin"" SET DEFAULT FALSE;";
await cmdAlterType.ExecuteNonQueryAsync();
}
}
}
// Check if column IsApproved in Tenants table is of type integer
using (var cmdCheckApproved = conn.CreateCommand())
{
cmdCheckApproved.CommandText = @"
SELECT data_type
FROM information_schema.columns
WHERE table_name = 'Tenants' AND column_name = 'IsApproved';";
var dataType = await cmdCheckApproved.ExecuteScalarAsync() as string;
if (dataType != null && dataType.Equals("integer", StringComparison.OrdinalIgnoreCase))
{
// Drop legacy integer default before changing the column type.
using (var cmdAlterApproved = conn.CreateCommand())
{
cmdAlterApproved.CommandText = @"
ALTER TABLE ""Tenants""
ALTER COLUMN ""IsApproved"" DROP DEFAULT,
ALTER COLUMN ""IsApproved"" TYPE boolean
USING (""IsApproved"" <> 0),
ALTER COLUMN ""IsApproved"" SET DEFAULT FALSE;";
await cmdAlterApproved.ExecuteNonQueryAsync();
}
}
}
// Check if column CreatedAt in Tenants table is of type text or character varying
using (var cmdCheckCreatedAt = conn.CreateCommand())
{
cmdCheckCreatedAt.CommandText = @"
SELECT data_type
FROM information_schema.columns
WHERE table_name = 'Tenants' AND column_name = 'CreatedAt';";
var dataType = await cmdCheckCreatedAt.ExecuteScalarAsync() as string;
if (dataType != null && (dataType.Equals("text", StringComparison.OrdinalIgnoreCase) || dataType.Equals("character varying", StringComparison.OrdinalIgnoreCase)))
{
// Alter the column type to timestamp without time zone
using (var cmdAlterCreatedAt = conn.CreateCommand())
{
cmdAlterCreatedAt.CommandText = @"
ALTER TABLE ""Tenants""
ALTER COLUMN ""CreatedAt"" TYPE timestamp without time zone
USING (""CreatedAt""::timestamp without time zone);";
await cmdAlterCreatedAt.ExecuteNonQueryAsync();
}
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Fehler beim Konvertieren der PostgreSQL-Spaltentypen (IsTenantAdmin/IsApproved/CreatedAt): {ex.Message}");
}
}
var superAdmin = await db.Users
.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.TenantId == null && u.Username == SuperAdminUsername);
if (superAdmin == null)
{
var (passwordHash, passwordSalt) = HashPassword(SuperAdminPassword);
db.Users.Add(new User
{
Username = SuperAdminUsername,
PasswordHash = passwordHash,
PasswordSalt = passwordSalt,
TenantId = null,
IsTenantAdmin = true
});
await db.SaveChangesAsync();
}
else if (IsLegacySeedPassword(superAdmin.PasswordHash, superAdmin.PasswordSalt, SuperAdminPassword))
{
var (passwordHash, passwordSalt) = HashPassword(SuperAdminPassword);
superAdmin.PasswordHash = passwordHash;
superAdmin.PasswordSalt = passwordSalt;
superAdmin.IsTenantAdmin = true;
await db.SaveChangesAsync();
}
} }
var forwardedHeadersOptions = new ForwardedHeadersOptions var forwardedHeadersOptions = new ForwardedHeadersOptions
@@ -515,6 +684,24 @@ trackerApi.MapGet("/month", async (ClaimsPrincipal claimsPrincipal, [FromQuery]
return Results.Ok(days); return Results.Ok(days);
}); });
trackerApi.MapGet("/dashboard/stats", async (ClaimsPrincipal claimsPrincipal, [FromQuery] int year, [FromQuery] int month, ITimetrackerService trackerService) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
var stats = await trackerService.GetDashboardStatsAsync(userId, year, month);
return Results.Ok(stats);
});
trackerApi.MapGet("/dashboard/chart", async (ClaimsPrincipal claimsPrincipal, [FromQuery] int year, ITimetrackerService trackerService) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
var chartData = await trackerService.GetDashboardChartDataAsync(userId, year);
return Results.Ok(chartData);
});
trackerApi.MapGet("/export/week", async (ClaimsPrincipal claimsPrincipal, [FromQuery] string monday, ITimetrackerService trackerService, TimetrackerDbContext db) => trackerApi.MapGet("/export/week", async (ClaimsPrincipal claimsPrincipal, [FromQuery] string monday, ITimetrackerService trackerService, TimetrackerDbContext db) =>
{ {
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
@@ -570,6 +757,51 @@ trackerApi.MapGet("/export/month", async (ClaimsPrincipal claimsPrincipal, [From
$"Monatsbericht_{monthName}_{year}.xlsx"); $"Monatsbericht_{monthName}_{year}.xlsx");
}); });
trackerApi.MapGet("/export/month/pdf", async (
ClaimsPrincipal claimsPrincipal,
[FromQuery] int userId,
[FromQuery] int year,
[FromQuery] int month,
ITimetrackerService trackerService,
IHolidayService holidayService,
TimetrackerDbContext db,
timetracker.Server.Services.PdfGeneratorService pdfService) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(idClaim, out var currentUserId)) return Results.Unauthorized();
if (userId != currentUserId && !claimsPrincipal.IsInRole("TenantAdmin") && !claimsPrincipal.IsInRole("SuperAdmin"))
{
return Results.Forbid();
}
if (month < 1 || month > 12) return Results.BadRequest("Ungültiger Monat.");
var user = await db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == userId);
if (user == null) return Results.NotFound("Benutzer nicht gefunden.");
Tenant? tenant = null;
if (user.TenantId.HasValue)
{
tenant = await db.Tenants.IgnoreQueryFilters().FirstOrDefaultAsync(t => t.Id == user.TenantId.Value);
}
var deCulture = new System.Globalization.CultureInfo("de-DE");
var monthName = deCulture.DateTimeFormat.GetMonthName(month);
var workDays = await trackerService.GetMonthAsync(userId, year, month);
var vacations = await trackerService.GetVacationDaysAsync(userId, year);
var settings = await trackerService.GetSettingsAsync(userId);
var holidays = await holidayService.GetHolidaysAsync(year, settings.GermanState);
var pdfBytes = pdfService.GenerateTimesheetPdf(user, tenant, settings, year, month, workDays, vacations, holidays);
return Results.File(
pdfBytes,
"application/pdf",
$"Stundenzettel_{monthName}_{year}_{user.Username}.pdf");
});
trackerApi.MapGet("/export/year", async (ClaimsPrincipal claimsPrincipal, [FromQuery] int year, ITimetrackerService trackerService, TimetrackerDbContext db) => trackerApi.MapGet("/export/year", async (ClaimsPrincipal claimsPrincipal, [FromQuery] int year, ITimetrackerService trackerService, TimetrackerDbContext db) =>
{ {
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
@@ -739,6 +971,54 @@ superAdminApi.MapDelete("/tenants/{id:int}", async (int id, TimetrackerDbContext
app.Run(); app.Run();
static (string Hash, string Salt) HashPassword(string password)
{
var saltBytes = RandomNumberGenerator.GetBytes(32);
var salt = Convert.ToBase64String(saltBytes);
var hash = ComputePasswordHash(password, salt);
return (hash, salt);
}
static bool IsLegacySeedPassword(string hash, string salt, string password)
{
var passwordBytes = Encoding.UTF8.GetBytes(password + salt);
var legacyHash = Convert.ToBase64String(SHA256.HashData(passwordBytes));
return hash == legacyHash;
}
static string ComputePasswordHash(string password, string salt)
{
var hash = Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(password),
Convert.FromBase64String(salt),
iterations: 200_000,
HashAlgorithmName.SHA256,
outputLength: 32);
return Convert.ToBase64String(hash);
}
static async Task EnsurePostgresIntegerPrimaryKeyDefaultAsync(System.Data.Common.DbConnection conn, string tableName)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = $@"
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = '{tableName}'
AND column_name = 'Id'
) THEN
EXECUTE 'CREATE SEQUENCE IF NOT EXISTS public.""{tableName}_Id_seq""';
EXECUTE 'ALTER SEQUENCE public.""{tableName}_Id_seq"" OWNED BY public.""{tableName}"".""Id""';
EXECUTE 'ALTER TABLE public.""{tableName}"" ALTER COLUMN ""Id"" SET DEFAULT nextval(''''public.""{tableName}_Id_seq""'''')';
EXECUTE 'SELECT setval(''''public.""{tableName}_Id_seq""'''', COALESCE((SELECT MAX(""Id"") FROM public.""{tableName}""), 0) + 1, false)';
END IF;
END $$;";
await cmd.ExecuteNonQueryAsync();
}
// ── Models for Request Bodies ────────────────────────────────────────────────── // ── Models for Request Bodies ──────────────────────────────────────────────────
public record LoginRequest(string Username, string Password); public record LoginRequest(string Username, string Password);
public record RegisterRequest(string Username, string Password, string? Honeypot = null); public record RegisterRequest(string Username, string Password, string? Honeypot = null);
@@ -0,0 +1,41 @@
using System;
using System.IO;
using System.Reflection;
using PdfSharp.Fonts;
namespace timetracker.Server.Services
{
public class PdfFontResolver : IFontResolver
{
public byte[] GetFont(string faceName)
{
var assembly = Assembly.GetExecutingAssembly();
// Map faceName to Embedded Resource stream
string resourceName = faceName.ToLower() switch
{
"opensans-bold" => "timetracker.Server.Fonts.OpenSans-Bold.ttf",
_ => "timetracker.Server.Fonts.OpenSans-Regular.ttf"
};
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream == null)
{
throw new InvalidOperationException($"Could not load font resource '{resourceName}'");
}
using var ms = new MemoryStream();
stream.CopyTo(ms);
return ms.ToArray();
}
public FontResolverInfo ResolveTypeface(string familyName, bool isBold, bool isItalic)
{
if (isBold)
{
return new FontResolverInfo("OpenSans-Bold");
}
return new FontResolverInfo("OpenSans-Regular");
}
}
}
@@ -0,0 +1,266 @@
using MigraDoc.DocumentObjectModel;
using MigraDoc.DocumentObjectModel.Tables;
using MigraDoc.Rendering;
using System;
using System.Collections.Generic;
using System.Linq;
using timetracker.Shared;
namespace timetracker.Server.Services
{
public class PdfGeneratorService
{
public byte[] GenerateTimesheetPdf(
User user,
Tenant? tenant,
AppSettings settings,
int year,
int month,
List<WorkDay> workDays,
List<VacationDay> vacationDays,
List<PublicHoliday> holidays)
{
if (PdfSharp.Fonts.GlobalFontSettings.FontResolver == null)
{
PdfSharp.Fonts.GlobalFontSettings.FontResolver = new PdfFontResolver();
}
var document = new Document();
document.Info.Title = "Stundenzettel";
document.Info.Author = tenant?.Name ?? "Timetracker";
var section = document.AddSection();
section.PageSetup.PageFormat = PageFormat.A4;
section.PageSetup.TopMargin = "2cm";
section.PageSetup.BottomMargin = "2cm";
section.PageSetup.LeftMargin = "2cm";
section.PageSetup.RightMargin = "2cm";
var style = document.Styles["Normal"];
if (style != null && style.Font != null)
{
style.Font.Name = "OpenSans";
style.Font.Size = 10;
}
ComposeHeader(section, user, tenant, year, month);
ComposeContent(section, document, user, settings, year, month, workDays, vacationDays, holidays);
ComposeFooter(section);
var pdfRenderer = new PdfDocumentRenderer()
{
Document = document
};
pdfRenderer.RenderDocument();
using var stream = new System.IO.MemoryStream();
pdfRenderer.PdfDocument.Save(stream, false);
return stream.ToArray();
}
private void ComposeHeader(Section section, User user, Tenant? tenant, int year, int month)
{
var monthName = new DateTime(year, month, 1).ToString("MMMM yyyy", new System.Globalization.CultureInfo("de-DE"));
var companyName = tenant?.Name ?? "Hauptdomain";
var paragraph = section.AddParagraph($"Stundenzettel - {monthName}");
paragraph.Format.Font.Size = 20;
paragraph.Format.Font.Bold = true;
paragraph.Format.Font.Color = Colors.DarkBlue;
paragraph.Format.SpaceAfter = "0.2cm";
paragraph = section.AddParagraph($"Mitarbeiter: {user.Username}");
paragraph.Format.Font.Size = 14;
paragraph.Format.Font.Bold = true;
paragraph.Format.SpaceAfter = "0.1cm";
paragraph = section.AddParagraph($"Firma: {companyName}");
paragraph.Format.Font.Size = 11;
paragraph.Format.Font.Color = Colors.DarkGray;
paragraph.Format.SpaceAfter = "1cm";
}
private void ComposeContent(Section section, Document document, User user, AppSettings settings, int year, int month, List<WorkDay> workDays, List<VacationDay> vacationDays, List<PublicHoliday> holidays)
{
ComposeTable(section, document, settings, year, month, workDays, vacationDays, holidays, out var totalNet, out var totalTarget, out var totalOvertime);
ComposeSummary(section, totalNet, totalTarget, totalOvertime);
ComposeSignatures(section);
}
private void ComposeTable(Section section, Document document, AppSettings settings, int year, int month, List<WorkDay> workDays, List<VacationDay> vacationDays, List<PublicHoliday> holidays, out TimeSpan totalNetOut, out TimeSpan totalTargetOut, out TimeSpan totalOvertimeOut)
{
var holidayMap = holidays.ToDictionary(h => h.Date, h => h.Name);
var vacationSet = vacationDays.Select(v => v.Date).ToHashSet();
var daysInMonth = DateTime.DaysInMonth(year, month);
var totalNet = TimeSpan.Zero;
var totalTarget = TimeSpan.Zero;
var totalOvertime = TimeSpan.Zero;
var table = section.AddTable();
table.Borders.Width = 0.5;
table.Borders.Color = Colors.LightGray;
// Define columns
table.AddColumn("2.5cm"); // Datum
table.AddColumn("1.5cm"); // Start
table.AddColumn("1.5cm"); // Ende
table.AddColumn("1.8cm"); // Pause
table.AddColumn("1.8cm"); // Netto
table.AddColumn("1.8cm"); // Gleitzeit
table.AddColumn("6.1cm"); // Info
// Header Row
var row = table.AddRow();
row.HeadingFormat = true;
row.Format.Font.Bold = true;
row.Shading.Color = Colors.White;
row.Borders.Bottom.Width = 1;
row.Borders.Bottom.Color = Colors.Black;
row.Cells[0].AddParagraph("Datum");
row.Cells[1].AddParagraph("Start").Format.Alignment = ParagraphAlignment.Right;
row.Cells[2].AddParagraph("Ende").Format.Alignment = ParagraphAlignment.Right;
row.Cells[3].AddParagraph("Pause").Format.Alignment = ParagraphAlignment.Right;
row.Cells[4].AddParagraph("Netto").Format.Alignment = ParagraphAlignment.Right;
row.Cells[5].AddParagraph("Gleitzeit").Format.Alignment = ParagraphAlignment.Right;
row.Cells[6].AddParagraph("Info/Status");
for (int day = 1; day <= daysInMonth; day++)
{
var date = new DateOnly(year, month, day);
var wd = workDays.FirstOrDefault(w => w.Date == date);
var isWorkDay = settings.IsWorkDay(date.DayOfWeek);
var isVacation = vacationSet.Contains(date);
var holidayName = holidayMap.GetValueOrDefault(date);
TimeSpan? net = null;
TimeSpan breakSum = TimeSpan.Zero;
if (wd?.StartTime != null && wd.EndTime != null)
{
var gross = wd.EndTime.Value.ToTimeSpan() - wd.StartTime.Value.ToTimeSpan();
breakSum = wd.Breaks
.Where(b => b.StartTime.HasValue && b.EndTime.HasValue && b.EndTime > b.StartTime)
.Aggregate(TimeSpan.Zero, (s, b) =>
s + (b.EndTime!.Value.ToTimeSpan() - b.StartTime!.Value.ToTimeSpan()));
var minMinutes = gross.TotalHours >= 9.0 ? 45 : 30;
var minBreak = TimeSpan.FromMinutes(Math.Max(minMinutes, settings.MinimumBreakMinutes));
var finalBreak = breakSum > minBreak ? breakSum : minBreak;
net = gross - finalBreak;
if (net < TimeSpan.Zero) net = null;
}
var target = isWorkDay ? TimeSpan.FromHours(settings.DailyTargetHours) : TimeSpan.Zero;
var overtime = net.HasValue ? net.Value - target : TimeSpan.Zero;
if (net.HasValue) totalNet += net.Value;
if (isWorkDay) totalTarget += target;
if (isWorkDay && net.HasValue) totalOvertime += overtime;
string status = "";
if (!string.IsNullOrEmpty(holidayName)) status = holidayName;
else if (isVacation) status = "Urlaub";
else if (!isWorkDay) status = "Wochenende";
var bgColor = Colors.White;
if (!isWorkDay || !string.IsNullOrEmpty(holidayName)) bgColor = new Color(245, 245, 245); // Light Gray
if (isVacation) bgColor = new Color(255, 236, 179); // Light Orange
row = table.AddRow();
row.Shading.Color = bgColor;
row.Cells[0].AddParagraph(date.ToString("dd.MM.yyyy"));
row.Cells[1].AddParagraph(wd?.StartTime?.ToString(@"HH\:mm") ?? "-").Format.Alignment = ParagraphAlignment.Right;
row.Cells[2].AddParagraph(wd?.EndTime?.ToString(@"HH\:mm") ?? "-").Format.Alignment = ParagraphAlignment.Right;
row.Cells[3].AddParagraph(net.HasValue ? FormatTs(breakSum) : "-").Format.Alignment = ParagraphAlignment.Right;
row.Cells[4].AddParagraph(net.HasValue ? FormatTs(net.Value) : "-").Format.Alignment = ParagraphAlignment.Right;
var overtimePara = row.Cells[5].AddParagraph(net.HasValue ? FormatTs(overtime, true) : "-");
overtimePara.Format.Alignment = ParagraphAlignment.Right;
if (net.HasValue && overtime != TimeSpan.Zero)
{
overtimePara.Format.Font.Color = overtime < TimeSpan.Zero ? Colors.DarkRed : Colors.DarkGreen;
}
var statusPara = row.Cells[6].AddParagraph(status);
statusPara.Format.Font.Color = Colors.DarkGray;
}
totalNetOut = totalNet;
totalTargetOut = totalTarget;
totalOvertimeOut = totalOvertime;
}
private void ComposeSummary(Section section, TimeSpan totalNet, TimeSpan totalTarget, TimeSpan totalOvertime)
{
var paragraph = section.AddParagraph();
paragraph.Format.SpaceBefore = "1cm";
var table = section.AddTable();
table.Shading.Color = new Color(245, 245, 245);
table.AddColumn("4cm");
table.AddColumn("4cm");
var row = table.AddRow();
row.Cells[0].AddParagraph("Zusammenfassung").Format.Font.Bold = true;
row.Cells[0].MergeRight = 1;
row = table.AddRow();
row.Cells[0].AddParagraph("Soll-Stunden:");
row.Cells[1].AddParagraph(FormatTs(totalTarget));
row = table.AddRow();
row.Cells[0].AddParagraph("Ist-Stunden:");
row.Cells[1].AddParagraph(FormatTs(totalNet));
row = table.AddRow();
row.Cells[0].AddParagraph("Saldo:");
var saldoPara = row.Cells[1].AddParagraph(FormatTs(totalOvertime, true));
saldoPara.Format.Font.Bold = true;
if (totalOvertime != TimeSpan.Zero)
{
saldoPara.Format.Font.Color = totalOvertime < TimeSpan.Zero ? Colors.DarkRed : Colors.DarkGreen;
}
}
private void ComposeSignatures(Section section)
{
var table = section.AddTable();
table.Format.SpaceBefore = "2cm";
table.AddColumn("7cm");
table.AddColumn("2cm");
table.AddColumn("7cm");
var row = table.AddRow();
row.Cells[0].Borders.Bottom.Width = 1;
row.Cells[2].Borders.Bottom.Width = 1;
row = table.AddRow();
row.Cells[0].AddParagraph("Datum, Unterschrift Mitarbeiter").Format.Font.Size = 8;
row.Cells[2].AddParagraph("Datum, Unterschrift Arbeitgeber").Format.Font.Size = 8;
}
private void ComposeFooter(Section section)
{
var paragraph = section.Footers.Primary.AddParagraph();
paragraph.AddText("Seite ");
paragraph.AddPageField();
paragraph.AddText(" von ");
paragraph.AddNumPagesField();
paragraph.Format.Alignment = ParagraphAlignment.Center;
}
private static string FormatTs(TimeSpan ts, bool sign = false)
{
var neg = ts < TimeSpan.Zero;
var abs = neg ? ts.Negate() : ts;
var h = (int)abs.TotalHours;
var m = abs.Minutes;
if (!sign) return $"{h}:{m:D2} h";
return neg ? $"-{h}:{m:D2} h" : $"+{h}:{m:D2} h";
}
}
}
@@ -1,4 +1,14 @@
{ {
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Information"
}
}
},
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
+10
View File
@@ -1,4 +1,14 @@
{ {
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Information"
}
}
},
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
File diff suppressed because it is too large Load Diff
@@ -14,6 +14,9 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
<PackageReference Include="MudBlazor" Version="9.4.0" /> <PackageReference Include="MudBlazor" Version="9.4.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="PDFsharp-MigraDoc" Version="6.2.4" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" /> <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
</ItemGroup> </ItemGroup>
@@ -22,4 +25,8 @@
<ProjectReference Include="..\timetracker.Client\timetracker.Client.csproj" /> <ProjectReference Include="..\timetracker.Client\timetracker.Client.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Fonts\*.ttf" />
</ItemGroup>
</Project> </Project>
+29
View File
@@ -0,0 +1,29 @@
using System;
using System.Collections.Generic;
namespace timetracker.Shared
{
public class DashboardStatsVm
{
public TimeSpan? EarliestStart { get; set; }
public TimeSpan? LatestEnd { get; set; }
public TimeSpan AverageBreak { get; set; }
public int CurrentStreak { get; set; }
public int LongestStreak { get; set; }
}
public class DashboardChartDataVm
{
public List<MonthlyOvertimeVm> MonthlyOvertime { get; set; } = new();
public int WorkDaysCount { get; set; }
public int VacationDaysCount { get; set; }
public int SickDaysCount { get; set; }
public int PublicHolidaysCount { get; set; }
}
public class MonthlyOvertimeVm
{
public string MonthName { get; set; } = "";
public double OvertimeHours { get; set; }
}
}
@@ -11,4 +11,6 @@ public interface ITimetrackerService
Task RemoveVacationDayAsync(int userId, int id); Task RemoveVacationDayAsync(int userId, int id);
Task<TimeSpan> GetTotalOvertimeAsync(int userId, AppSettings settings); Task<TimeSpan> GetTotalOvertimeAsync(int userId, AppSettings settings);
Task<List<WorkDay>> GetMonthAsync(int userId, int year, int month); Task<List<WorkDay>> GetMonthAsync(int userId, int year, int month);
Task<DashboardStatsVm> GetDashboardStatsAsync(int userId, int year, int month);
Task<DashboardChartDataVm> GetDashboardChartDataAsync(int userId, int year);
} }
+1
View File
@@ -5,4 +5,5 @@ public class UserInfo
public int Id { get; set; } public int Id { get; set; }
public string Username { get; set; } = ""; public string Username { get; set; } = "";
public bool IsTenantAdmin { get; set; } public bool IsTenantAdmin { get; set; }
public bool IsAuthenticated { get; set; }
} }