33 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
mrcwlnd 807d56d071 Merge pull request 'asynchroner tenant' (#14) from testbranch into main
Reviewed-on: #14
2026-06-25 19:04:19 +00:00
MarcWieland b6dc66744f asynchroner tenant 2026-06-25 21:04:04 +02:00
mrcwlnd c77cd7d524 Merge pull request 'fixed' (#13) from testbranch into main
Reviewed-on: #13
2026-06-25 18:59:26 +00:00
MarcWieland a01f80bb14 fixed 2026-06-25 20:59:10 +02:00
mrcwlnd 17c8e5e9ed Merge pull request 'feat: ignore pending model changes warnings and update EF Core version in DbContext configuration' (#12) from testbranch into main
Reviewed-on: #12
2026-06-25 18:49:21 +00:00
MarcWieland 9980d9217a feat: ignore pending model changes warnings and update EF Core version in DbContext configuration 2026-06-25 20:49:00 +02:00
mrcwlnd 9bcce06842 Merge pull request 'fixed database' (#11) from testbranch into main
Reviewed-on: #11
2026-06-25 18:41:18 +00:00
MarcWieland 03b025c09c fixed database 2026-06-25 20:40:28 +02:00
mrcwlnd 528eeb13a1 Merge pull request 'Multi tenant integration' (#10) from multi-tenant-integration into main
Reviewed-on: #10
2026-06-25 18:34:05 +00:00
mrcwlnd d5f68ca520 Merge branch 'main' into multi-tenant-integration 2026-06-25 18:34:01 +00:00
MarcWieland 0719d41fe1 changelog angepasst 2026-06-25 20:23:39 +02:00
MarcWieland b12e94128c Excel Export eingebaut 2026-06-25 20:22:35 +02:00
MarcWieland 2f95fa0e3e auch einzelnutzer können sich registrieren 2026-06-25 20:15:07 +02:00
MarcWieland ac06059e2a multi-tenant implementierung 2026-06-24 23:48:04 +02:00
67 changed files with 8920 additions and 386 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
- ConnectionStrings__DefaultConnection=Host=db;Database=timetracker;Username=timetracker_user;Password=SecretPassword123;
- 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:
- 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:
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"); } }
@@ -3,6 +3,7 @@
@inject NavigationManager Nav
@inject IUserNotificationService UserNotificationService
@inject IJSRuntime JSRuntime
@inject HttpClient Http
@implements IDisposable
@using System.Security.Claims
@@ -12,7 +13,7 @@
<MudSnackbarProvider />
<MudLayout>
<MudAppBar Elevation="0" Style="background: #0F172A; border-bottom: 1px solid rgba(255, 255, 255, 0.08);">
<MudAppBar Elevation="0" Style="background: color-mix(in srgb, var(--mud-palette-appbar-background) 85%, transparent); border-bottom: 1px solid rgba(255, 255, 255, 0.08); color: var(--mud-palette-appbar-text);">
<AuthorizeView>
<Authorized>
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start" OnClick="ToggleDrawer" Class="d-flex d-md-none mr-2" />
@@ -28,7 +29,7 @@
<AuthorizeView>
<Authorized>
<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>
</Authorized>
</AuthorizeView>
@@ -47,12 +48,96 @@
</MudLayout>
@code {
[PersistentState(AllowUpdates = true)]
public TenantInfo? PersistentTenantInfo { get; set; }
private bool _drawerOpen = true;
private bool _isDarkMode;
protected override void OnInitialized()
protected override async Task OnInitializedAsync()
{
UserNotificationService.OnUserDeleted += HandleUserDeleted;
if (PersistentTenantInfo != null)
{
ApplyTheme(PersistentTenantInfo);
}
else
{
try
{
var tenantInfo = await Http.GetFromJsonAsync<TenantInfo>("api/tenant/current");
if (tenantInfo != null)
{
ApplyTheme(tenantInfo);
PersistentTenantInfo = tenantInfo;
}
}
catch
{
// Ignore background error
}
}
}
private void ApplyTheme(TenantInfo tenantInfo)
{
if (tenantInfo.IsTenant && !string.IsNullOrEmpty(tenantInfo.PrimaryColor))
{
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;
}
}
private bool IsLightColor(string hexColor)
{
if (string.IsNullOrEmpty(hexColor)) return false;
hexColor = hexColor.TrimStart('#');
if (hexColor.Length == 3)
{
hexColor = new string(new[] { hexColor[0], hexColor[0], hexColor[1], hexColor[1], hexColor[2], hexColor[2] });
}
if (hexColor.Length != 6) return false;
try
{
var r = Convert.ToInt32(hexColor.Substring(0, 2), 16);
var g = Convert.ToInt32(hexColor.Substring(2, 2), 16);
var b = Convert.ToInt32(hexColor.Substring(4, 2), 16);
var luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255.0;
return luminance > 0.6; // slightly conservative threshold for white text readability
}
catch
{
return false;
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -14,6 +14,7 @@
}
::deep .modern-drawer {
background-color: var(--mud-palette-drawer-background) !important;
border-right: 1px solid rgba(255, 255, 255, 0.08) !important;
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.1) !important;
transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1) !important;
@@ -21,7 +22,7 @@
/* Glassmorphism for the appbar */
::deep .mud-appbar {
background: rgba(15, 23, 42, 0.85) !important;
background: color-mix(in srgb, var(--mud-palette-appbar-background) 85%, transparent) !important;
backdrop-filter: blur(12px) !important;
-webkit-backdrop-filter: blur(12px) !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important;
@@ -1,9 +1,10 @@
@inject IJSRuntime JSRuntime
<div class="nav-container onboarding-nav-menu">
@inject HttpClient Http
<div class="nav-container onboarding-nav-menu @(IsCollapsed ? "collapsed" : "")">
@* --- Brand / Logo Section --- *@
<div class="nav-header">
<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" />
<span class="brand-text">Timetracker</span>
</div>
@@ -12,8 +13,11 @@
@* --- Navigation Links --- *@
<div class="nav-content">
<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">
<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 Text="Monatsübersicht" Placement="Placement.Right">
<MudNavLink Href="month" Icon="@Icons.Material.Filled.CalendarViewMonth" Class="custom-nav-link">Monatsübersicht</MudNavLink>
@@ -37,6 +41,11 @@
<span class="admin-divider-text">Admin</span>
<hr class="admin-divider-line" />
</div>
<MudTooltip Text="Firmen verwalten (SaaS)" Placement="Placement.Right">
<MudNavLink Href="superadmin" Icon="@Icons.Material.Filled.Business" Class="custom-nav-link admin-link">
Firmen verwalten
</MudNavLink>
</MudTooltip>
<MudTooltip Text="Benutzerverwaltung" Placement="Placement.Right">
<MudNavLink Href="admin/users" Icon="@Icons.Material.Filled.AdminPanelSettings" Class="custom-nav-link admin-link">
Benutzerverwaltung
@@ -61,7 +70,7 @@
</div>
<div class="user-info">
<span class="user-name">@context.User.Identity?.Name</span>
<span class="user-status">Online</span>
<span class="user-status">@GetUserStatus(context.User.Identity?.Name)</span>
</div>
</div>
</NavLink>
@@ -90,9 +99,38 @@
</div>
@code {
[PersistentState(AllowUpdates = true)]
public string? PersistentCompanyName { get; set; }
[Parameter] public EventCallback OnToggleDrawer { get; set; }
[Parameter] public bool IsCollapsed { get; set; }
private bool _showVersionBadge;
private string _companyName = "Hauptdomain";
protected override async Task OnInitializedAsync()
{
if (PersistentCompanyName != null)
{
_companyName = PersistentCompanyName;
}
else
{
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
}
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
@@ -136,4 +174,12 @@
return parts[0][..Math.Min(2, parts[0].Length)].ToUpper();
return $"{parts[0][0]}{parts[1][0]}".ToUpper();
}
private string GetUserStatus(string? username)
{
if (string.IsNullOrWhiteSpace(username)) return "";
if (username.Equals("marc", StringComparison.OrdinalIgnoreCase)) return "SuperAdmin";
if (_companyName == "Hauptdomain") return "Einzelnutzer";
return _companyName;
}
}
@@ -44,7 +44,7 @@
}
::deep .logo-icon {
color: #0EA5E9 !important; /* Sky 500 */
color: var(--mud-palette-secondary) !important;
font-size: 1.6rem !important;
transition: opacity 0.2s ease;
}
@@ -52,7 +52,7 @@
.brand-text {
font-size: 1.1rem;
font-weight: 800;
color: #FFFFFF;
color: var(--mud-palette-appbar-text);
letter-spacing: 0.5px;
white-space: nowrap;
opacity: 1;
@@ -104,7 +104,7 @@
::deep .custom-nav-link,
::deep .custom-nav-link .mud-nav-link-text,
::deep .custom-nav-link .mud-nav-link-icon-default {
color: #94A3B8 !important; /* Slate 400 */
color: var(--mud-palette-drawer-text) !important;
}
/* NavLink hover */
@@ -115,44 +115,64 @@
::deep .custom-nav-link:hover,
::deep .custom-nav-link:hover .mud-nav-link-text,
::deep .custom-nav-link:hover .mud-nav-link-icon-default {
color: #F8FAFC !important; /* Slate 50 */
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 {
background: linear-gradient(90deg, rgba(14, 165, 233, 0.15) 0%, rgba(14, 165, 233, 0.04) 100%) !important;
background: transparent !important;
font-weight: 600 !important;
box-shadow: inset 3px 0 0 #0EA5E9 !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 {
color: var(--mud-palette-secondary) !important;
}
::deep .custom-nav-link.active .mud-nav-link-icon-default,
::deep .custom-nav-link .active .mud-nav-link-icon-default {
color: #0EA5E9 !important; /* Sky 500 */
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 color overrides & active state */
/* Admin link active state (Circle around icon) */
::deep .admin-link .mud-nav-link-icon-default {
color: rgba(239, 68, 68, 0.7) !important; /* Red 500 */
}
::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;
box-shadow: inset 3px 0 0 #EF4444 !important;
background: transparent !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 {
color: #EF4444 !important;
}
::deep .admin-link.active .mud-nav-link-icon-default,
::deep .admin-link .active .mud-nav-link-icon-default {
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 */
@@ -208,7 +228,7 @@
::deep .user-avatar {
width: 36px !important;
height: 36px !important;
background: linear-gradient(135deg, #0EA5E9 0%, #0284C7 100%) !important;
background: linear-gradient(135deg, var(--mud-palette-secondary) 0%, var(--mud-palette-secondary-darken) 100%) !important;
color: #FFFFFF !important;
font-weight: 700 !important;
font-size: 0.875rem !important;
@@ -222,7 +242,7 @@
width: 10px;
height: 10px;
background-color: #10B981; /* Emerald 500 */
border: 2px solid #0F172A;
border: 2px solid var(--mud-palette-drawer-background);
border-radius: 50%;
}
@@ -236,7 +256,7 @@
.user-name {
font-size: 0.85rem;
font-weight: 600;
color: #FFFFFF;
color: var(--mud-palette-appbar-text);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
@@ -295,63 +315,89 @@
/* --- COLLAPSED DRAWER (MINI STATE) STYLES --- */
/* Parent matches the mini drawer element (Note the correct Blazor CSS isolation placement) */
.mud-drawer--closed ::deep .logo-icon,
.mud-drawer--closed ::deep .brand-text {
display: none !important;
/* Smooth transitions for all elements that change */
::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 {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.collapsed ::deep .logo-icon,
.collapsed .brand-text {
opacity: 0;
width: 0;
max-width: 0;
overflow: hidden;
margin: 0;
padding: 0;
transform: scale(0);
}
.mud-drawer--closed ::deep .logo-wrapper {
justify-content: center !important;
.collapsed .logo-wrapper {
justify-content: center;
gap: 0;
}
.mud-drawer--closed ::deep .drawer-toggle-btn {
.collapsed ::deep .drawer-toggle-btn {
margin-left: 0 !important;
}
.mud-drawer--closed ::deep .admin-divider-container {
padding: 16px 0 8px 0 !important;
justify-content: center !important;
.collapsed .admin-divider-container {
padding: 16px 0 8px 0;
justify-content: center;
}
.mud-drawer--closed ::deep .admin-divider-text {
display: none !important;
.collapsed .admin-divider-text {
opacity: 0;
width: 0;
overflow: hidden;
margin: 0;
}
.mud-drawer--closed ::deep .admin-divider-line {
width: 24px !important;
flex: none !important;
.collapsed .admin-divider-line {
width: 24px;
flex: none;
}
.mud-drawer--closed ::deep .user-profile-card {
justify-content: center !important;
padding: 8px 0 !important;
.collapsed .user-profile-card {
justify-content: center;
padding: 8px 0;
gap: 0;
}
.mud-drawer--closed ::deep .user-info {
display: none !important;
.collapsed .user-info {
opacity: 0;
width: 0;
overflow: hidden;
margin: 0;
}
.mud-drawer--closed ::deep .version-text {
display: none !important;
.collapsed .version-text {
opacity: 0;
width: 0;
overflow: hidden;
margin: 0;
}
/* Ensure link text is hidden in mini mode to prevent clunky clipping */
.mud-drawer--closed ::deep .custom-nav-link .mud-nav-link-text {
display: none !important;
/* 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. */
.collapsed ::deep .custom-nav-link .mud-nav-link-text {
opacity: 0;
width: 0;
overflow: hidden;
margin: 0 !important;
white-space: nowrap;
}
/* Center link icons when mini and adjust spacing */
.mud-drawer--closed ::deep .custom-nav-link {
margin: 4px 6px !important;
.collapsed ::deep .custom-nav-link {
margin: 4px auto !important;
padding: 8px 0 !important;
justify-content: center !important;
min-height: 40px !important;
width: 44px !important;
min-height: 44px !important;
display: flex !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;
}
@@ -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();
}
@@ -1,10 +1,15 @@
@page "/admin/users"
@rendermode InteractiveWebAssembly
@attribute [Authorize(Policy = "AdminOnly")]
@using System.Net.Http.Json
@using System.Security.Claims
@inject IAuthService AuthService
@inject ISnackbar Snackbar
@inject AuthenticationStateProvider AuthStateProvider
@inject IUserNotificationService UserNotificationService
@inject HttpClient Http
@inject IDialogService DialogService
@inject NavigationManager NavigationManager
@implements IDisposable
<PageTitle>Benutzerverwaltung Timetracker</PageTitle>
@@ -42,6 +47,7 @@ else
<HeaderContent>
<MudTh><MudTableSortLabel SortBy="new Func<User, object>(u => u.Id)">ID</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortBy="new Func<User, object>(u => u.Username)" InitialDirection="SortDirection.Ascending">Benutzername</MudTableSortLabel></MudTh>
<MudTh>Firma / Mandant</MudTh>
<MudTh Style="text-align:right">Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
@@ -74,6 +80,34 @@ else
</MudStack>
}
</MudTd>
<MudTd>
@if (_editUserId == context.Id)
{
<MudSelect @bind-Value="_editTenantId" Margin="Margin.Dense" T="int?" Label="Firma zuweisen" Variant="Variant.Outlined" Style="max-width:250px">
<MudSelectItem T="int?" Value="@((int?)null)">Hauptdomain (System/Global)</MudSelectItem>
@foreach (var tenant in _tenants)
{
<MudSelectItem T="int?" Value="@((int?)tenant.Id)">@tenant.Name (@tenant.Subdomain)</MudSelectItem>
}
</MudSelect>
}
else
{
@if (context.TenantId.HasValue)
{
var t = _tenants.FirstOrDefault(x => x.Id == context.TenantId.Value);
<MudChip T="string" Color="Color.Primary" Variant="Variant.Outlined" Size="Size.Small">
@(t?.Name ?? $"Firma #{context.TenantId}")
</MudChip>
}
else
{
<MudChip T="string" Color="Color.Default" Variant="Variant.Text" Size="Size.Small">
@(context.Username == "marc" ? "SuperAdmin" : "Einzelnutzer")
</MudChip>
}
}
</MudTd>
<MudTd Style="text-align:right">
@if (_editUserId == context.Id)
{
@@ -92,6 +126,18 @@ else
Color="Color.Primary"
Size="Size.Small"
OnClick="@(() => StartEdit(context))" />
<MudTooltip Text="Passwort zurücksetzen">
<MudIconButton Icon="@Icons.Material.Filled.LockReset"
Color="Color.Warning"
Size="Size.Small"
OnClick="@(() => OpenResetPasswordDialog(context))" />
</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")
{
<MudIconButton Icon="@Icons.Material.Filled.DeleteOutline"
@@ -113,25 +159,63 @@ else
}
@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<TenantStats> _tenants = [];
private bool _loading = true;
private int? _editUserId;
private string _editUsername = "";
private int? _editTenantId;
protected override async Task OnInitializedAsync()
{
if (PersistentUsers != null)
{
_users = PersistentUsers;
_tenants = PersistentTenants ?? [];
_loading = false;
}
var claim = (await AuthStateProvider.GetAuthenticationStateAsync())
.User.FindFirst(ClaimTypes.NameIdentifier);
if (claim == null) return;
UserNotificationService.OnUsersChanged += RefreshUsers;
if (PersistentUsers == null)
{
try
{
_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);
}
}
}
private async Task RefreshUsers()
{
_users = await AuthService.GetAllUsersAsync();
try
{
_tenants = await Http.GetFromJsonAsync<List<TenantStats>>("api/superadmin/tenants") ?? [];
}
catch
{
// Ignore background refresh errors
}
await InvokeAsync(StateHasChanged);
}
@@ -144,17 +228,23 @@ else
{
_editUserId = user.Id;
_editUsername = user.Username;
_editTenantId = user.TenantId;
}
private void CancelEdit()
{
_editUserId = null;
_editUsername = "";
_editTenantId = null;
}
private async Task SaveRename(User user)
{
var trimmed = _editUsername.Trim();
// 1. Rename if username changed
if (trimmed != user.Username)
{
var error = await AuthService.RenameUserAsync(user.Id, trimmed);
if (error != null)
{
@@ -162,9 +252,25 @@ else
return;
}
user.Username = trimmed;
}
// 2. Move to tenant if tenant ID changed
if (_editTenantId != user.TenantId)
{
var error = await AuthService.AssignTenantAsync(user.Id, _editTenantId);
if (error != null)
{
Snackbar.Add(error, Severity.Error);
return;
}
user.TenantId = _editTenantId;
}
_editUserId = null;
_editUsername = "";
Snackbar.Add($"Benutzer umbenannt zu \"{trimmed}\".", Severity.Success);
_editTenantId = null;
Snackbar.Add("Benutzerdaten erfolgreich aktualisiert.", Severity.Success);
}
private async Task DeleteUser(User user)
@@ -173,4 +279,44 @@ else
_users.Remove(user);
Snackbar.Add($"Benutzer \"{user.Username}\" gelöscht.", Severity.Info);
}
private async Task OpenResetPasswordDialog(User user)
{
var parameters = new DialogParameters<ResetPasswordDialog>
{
{ x => x.Username, user.Username }
};
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = await DialogService.ShowAsync<ResetPasswordDialog>("Passwort zurücksetzen", parameters, options);
var result = await dialog.Result;
if (result != null && !result.Canceled && result.Data is string newPassword)
{
var error = await AuthService.ResetPasswordAsync(user.Id, newPassword);
if (error != null)
{
Snackbar.Add(error, Severity.Error);
}
else
{
Snackbar.Add($"Passwort für {user.Username} wurde erfolgreich zurückgesetzt.", Severity.Success);
}
}
}
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);
}
}
}
@@ -8,12 +8,12 @@
<MudStack Spacing="4">
@* ── Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl" Style="background: #1E293B; color: white;">
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
<MudIcon Icon="@Icons.Material.Filled.Gavel" Style="color: white; font-size: 2.2rem;" />
<MudIcon Icon="@Icons.Material.Filled.Gavel" Style="color: var(--mud-palette-primary-text); font-size: 2.2rem;" />
<MudStack Spacing="0">
<MudText Typo="Typo.h5" Style="color: white; font-weight: 700;">Allgemeine Geschäftsbedingungen (AGB)</MudText>
<MudText Typo="Typo.caption" Style="color: rgba(255,255,255,0.72);">
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight: 700;">Allgemeine Geschäftsbedingungen (AGB)</MudText>
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72;">
Rechtlich absolut unverbindliches Kauderwelsch und Flachwitze
</MudText>
</MudStack>
@@ -8,13 +8,12 @@
<MudStack Spacing="4">
@* ── Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: #1E293B; color:white;">
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
<MudIcon Icon="@Icons.Material.Filled.NewReleases" Style="color:white; font-size:2rem" />
<MudIcon Icon="@Icons.Material.Filled.NewReleases" Style="color: var(--mud-palette-primary-text); font-size:2rem" />
<MudStack Spacing="0">
<MudText Typo="Typo.h5" Style="color:white; font-weight:700">Changelog</MudText>
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">Versionshistorie &amp; Änderungen</MudText>
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700">Changelog</MudText>
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72">Versionshistorie &amp; Änderungen</MudText>
</MudStack>
</MudStack>
</MudPaper>
@@ -61,12 +60,19 @@
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("Upgrade", "Früheste Gehenszeit wird angezeigt"),
new("Upgrade", "Pausen werden dynamisch berechnet"),
], Highlighted: true),
new("Neu", "Multi-Tenant Integration"),
new("Upgrade", "Verbesserte Single-User Ansicht")
]),
new("1.4", "08.06.2026", true,
[
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 {
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 int _year = DateTime.Today.Year;
private List<PublicHoliday> _holidays = [];
@@ -193,16 +205,39 @@ else
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 claim = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
if (claim != null)
{
_userId = int.Parse(claim.Value);
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;
}
}
private async Task LoadHolidays()
{
+80 -23
View File
@@ -1,4 +1,4 @@
@page "/"
@page "/week"
@rendermode InteractiveWebAssembly
@attribute [Authorize]
@inject ITimetrackerService TrackerService
@@ -6,6 +6,7 @@
@inject ISnackbar Snackbar
@inject AuthenticationStateProvider AuthStateProvider
@inject IJSRuntime JSRuntime
@inject NavigationManager NavigationManager
<PageTitle>KW @_kw Wochenübersicht Timetracker</PageTitle>
@@ -21,29 +22,32 @@ else
<MudStack Spacing="3">
@* ── Wochen-Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl onboarding-week-header"
Style="background: #1E293B; color: white;">
<MudPaper Elevation="4" Class="pa-5 rounded-xl onboarding-week-header mud-theme-primary">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
Style="color:white" Size="Size.Large" OnClick="PrevWeek" />
Style="color: var(--mud-palette-primary-text)" Size="Size.Large" OnClick="PrevWeek" />
<MudStack Spacing="0" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5" Style="color:white; font-weight:700; letter-spacing:0.5px">
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700; letter-spacing:0.5px">
@_weekLabel
</MudText>
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72">
@_weekSubLabel
</MudText>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
<MudTooltip Text="Woche als Excel exportieren">
<MudIconButton Icon="@Icons.Material.Filled.Download"
Style="color: var(--mud-palette-primary-text)" OnClick="ExportWeekExcel" />
</MudTooltip>
@if (!IsCurrentWeek)
{
<MudButton Variant="Variant.Text" Style="color:white"
<MudButton Variant="Variant.Text" Style="color: var(--mud-palette-primary-text)"
OnClick="GoToCurrentWeek" Size="Size.Small">
Heute
</MudButton>
}
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
Style="color:white" Size="Size.Large" OnClick="NextWeek" />
Style="color: var(--mud-palette-primary-text)" Size="Size.Large" OnClick="NextWeek" />
</MudStack>
</MudStack>
</MudPaper>
@@ -316,34 +320,34 @@ else
@* ── Wochensumme ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl onboarding-week-summary"
Style="background: #0F172A; color:white;">
Style="background: var(--mud-palette-primary-darken); color: var(--mud-palette-primary-text);">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="mb-4">
<MudIcon Icon="@Icons.Material.Filled.Summarize" Style="color:rgba(255,255,255,0.8)" />
<MudText Typo="Typo.h6" Style="color:white; font-weight:600">Wochensumme</MudText>
<MudIcon Icon="@Icons.Material.Filled.Summarize" Style="color: var(--mud-palette-primary-text); opacity: 0.8" />
<MudText Typo="Typo.h6" Style="color: var(--mud-palette-primary-text); font-weight:600">Wochensumme</MudText>
</MudStack>
<MudGrid Spacing="3">
<MudItem xs="6" sm="3">
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Brutto</MudText>
<MudText Typo="Typo.h5" Style="color:white">@FormatTs(WeekGross)</MudText>
<MudText Typo="Typo.overline" Style="color: var(--mud-palette-primary-text); opacity: 0.6">Brutto</MudText>
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text)">@FormatTs(WeekGross)</MudText>
</MudStack>
</MudItem>
<MudItem xs="6" sm="3">
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Pausen</MudText>
<MudText Typo="Typo.h5" Style="color:white">@FormatTs(WeekBreaks)</MudText>
<MudText Typo="Typo.overline" Style="color: var(--mud-palette-primary-text); opacity: 0.6">Pausen</MudText>
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text)">@FormatTs(WeekBreaks)</MudText>
</MudStack>
</MudItem>
<MudItem xs="6" sm="3">
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Netto</MudText>
<MudText Typo="Typo.h5" Style="color:#90CAF9; font-weight:700">@FormatTs(WeekNet)</MudText>
<MudText Typo="Typo.overline" Style="color: var(--mud-palette-primary-text); opacity: 0.6">Netto</MudText>
<MudText Typo="Typo.h5" Color="Color.Secondary" Style="font-weight:700">@FormatTs(WeekNet)</MudText>
</MudStack>
</MudItem>
<MudItem xs="6" sm="3">
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Gleitzeit</MudText>
<MudText Typo="Typo.h5" Style="@($"color:{(WeekOvertime >= TimeSpan.Zero ? "#A5D6A7" : "#FFCC80")}; font-weight:700")">
<MudText Typo="Typo.overline" Style="color: var(--mud-palette-primary-text); opacity: 0.6">Gleitzeit</MudText>
<MudText Typo="Typo.h5" Color="@(WeekOvertime >= TimeSpan.Zero ? Color.Success : Color.Warning)" Style="font-weight:700">
@FormatTs(WeekOvertime, sign: true)
</MudText>
</MudStack>
@@ -384,6 +388,30 @@ else
@code {
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 int _userId;
private DateOnly _monday;
@@ -405,11 +433,27 @@ else
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 claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
if (claim == null) return; // Prerender-Pass Circuit noch nicht authentifiziert
_userId = int.Parse(claim.Value);
_monday = GetMonday(DateOnly.FromDateTime(DateTime.Today));
if (PersistentSettings == null || PersistentDays == null || PersistentTotalOvertime == null)
{
_settings = await TrackerService.GetSettingsAsync(_userId);
var loadWeekTask = LoadWeek();
@@ -418,6 +462,17 @@ else
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
{
var showOnb = await JSRuntime.InvokeAsync<string>("localStorage.getItem", "showOnboarding");
@@ -427,8 +482,6 @@ else
{
// Ignored during prerendering/SSR
}
_loading = false;
}
private async Task HandleOnboardingFinished()
@@ -492,6 +545,10 @@ else
private async Task PrevWeek() { _monday = _monday.AddDays(-7); await LoadWeek(); }
private async Task NextWeek() { _monday = _monday.AddDays(7); await LoadWeek(); }
private async Task GoToCurrentWeek() { _monday = GetMonday(DateOnly.FromDateTime(DateTime.Today)); await LoadWeek(); }
private void ExportWeekExcel()
{
NavigationManager.NavigateTo($"api/tracker/export/week?monday={_monday:yyyy-MM-dd}", forceLoad: true);
}
private static DateOnly GetMonday(DateOnly date)
{
@@ -685,7 +742,7 @@ else
}
// ── ViewModels ────────────────────────────────────────────────
private sealed class DayVm
public sealed class DayVm
{
public int Id { get; set; }
public int UserId { get; set; }
@@ -735,7 +792,7 @@ else
};
}
private sealed class BreakVm
public sealed class BreakVm
{
public int Id { get; set; }
public TimeSpan? Start { get; set; }
+391 -6
View File
@@ -1,12 +1,61 @@
@page "/login"
@rendermode InteractiveWebAssembly
@attribute [AllowAnonymous]
@using System.Net.Http.Json
@inject IAuthService AuthService
@inject NavigationManager Nav
@inject IJSRuntime JSRuntime
@inject HttpClient Http
<PageTitle>Anmelden Timetracker</PageTitle>
<PageTitle>@GetPageTitle()</PageTitle>
@if (TenantInfo == null)
{
@* Loading indicator while resolving Tenant *@
<MudContainer MaxWidth="MaxWidth.Small" Class="mt-16 text-center">
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Large" />
<MudText Class="mt-4" Color="Color.Secondary">Lade Timetracker…</MudText>
</MudContainer>
}
else if (TenantInfo.IsTenant && !TenantInfo.IsApproved)
{
@* Pending Approval Screen *@
<MudContainer MaxWidth="MaxWidth.Small" Class="mt-16">
<MudStack AlignItems="AlignItems.Center" Spacing="4">
<MudStack AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@Icons.Material.Filled.AccessTime" Style="font-size:4rem; color:#EAB308" />
<MudText Typo="Typo.h4" Style="font-weight:700; color:#EAB308">Timetracker</MudText>
</MudStack>
<MudPaper Elevation="4" Class="pa-8 rounded-xl text-center" Style="width:100%; border-top: 4px solid var(--mud-palette-warning);">
<MudIcon Icon="@Icons.Material.Filled.HourglassEmpty"
Style="font-size: 5rem; animation: pulse 2s infinite;"
Color="Color.Warning"
Class="mb-4" />
<MudText Typo="Typo.h5" Style="font-weight: 700;" Class="mb-2">Freischaltung ausstehend</MudText>
<MudText Typo="Typo.body1" Color="Color.Secondary" Class="mb-6">
Die Firma <strong>@TenantInfo.Name</strong> wurde erfolgreich registriert.
Der Zugang wird derzeit von unserem SuperAdmin geprüft und in Kürze freigeschaltet.
</MudText>
<MudDivider Class="my-4" />
<MudText Typo="Typo.caption" Color="Color.Secondary">
Bitte laden Sie diese Seite in Kürze neu, sobald Ihr Account freigegeben wurde.
</MudText>
</MudPaper>
</MudStack>
</MudContainer>
<style>
@@keyframes pulse {
0% { opacity: 0.6; transform: scale(1); }
50% { opacity: 1; transform: scale(1.05); }
100% { opacity: 0.6; transform: scale(1); }
}
</style>
}
else
{
@* Main login/registration container *@
<MudContainer MaxWidth="MaxWidth.Small" Class="mt-16">
<MudStack AlignItems="AlignItems.Center" Spacing="4">
@@ -14,10 +63,47 @@
<MudStack AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@Icons.Material.Filled.AccessTime"
Style="font-size:4rem; color:#0EA5E9" />
<MudText Typo="Typo.h4" Style="font-weight:700; color:#0EA5E9">Timetracker</MudText>
<MudText Typo="Typo.h4" Style="font-weight:700; color:#0EA5E9">
@(TenantInfo.IsTenant ? TenantInfo.Name : "Timetracker SaaS")
</MudText>
@if (!TenantInfo.IsTenant)
{
<MudText Typo="Typo.caption" Color="Color.Secondary">Zentrales Portal</MudText>
}
</MudStack>
<MudPaper Elevation="4" Class="pa-6 rounded-xl" Style="width:100%">
@if (_firmRegisteredSuccessfully)
{
@* Success feedback after firm registration *@
<MudStack Spacing="4" AlignItems="AlignItems.Center" Class="text-center pa-4">
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Style="font-size:5rem;" />
<MudText Typo="Typo.h5" Style="font-weight:700;">Registrierung erfolgreich!</MudText>
<MudText Typo="Typo.body1" Color="Color.Secondary">
Ihre Firma <strong>@_firmModel.CompanyName</strong> wurde erfolgreich registriert.
</MudText>
<MudAlert Severity="Severity.Info" Class="rounded-lg">
Ihr Zugang muss nun durch den SuperAdmin freigegeben werden. Danach können Sie sich unter Ihrer Subdomain anmelden:
<br /><br />
<strong>@GetTenantUrl()</strong>
</MudAlert>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Class="mt-4" OnClick="ResetRegistrationFlow">
Zurück zum Login
</MudButton>
</MudStack>
} else {
@* ── Header / Navigation ── *@
@if (_activeTab == 2)
{
@* ── Back Button for Firm Registration ── *@
<MudStack Row="true" AlignItems="AlignItems.Center" Class="mb-4">
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" OnClick="@(() => SetTab(0))" Color="Color.Primary" />
<MudText Typo="Typo.h6" Style="font-weight:700">Firma registrieren</MudText>
</MudStack>
<MudDivider Class="mb-6" />
}
else
{
@* ── Tab Navigation ── *@
<MudStack Row="true" Justify="Justify.Center" Class="mb-4">
<MudButton OnClick="@(() => SetTab(0))"
@@ -33,8 +119,8 @@
Registrieren
</MudButton>
</MudStack>
<MudDivider Class="mb-6" />
}
@if (_activeTab == 0)
{
@@ -80,9 +166,11 @@
</EditForm>
</MudStack>
}
else
else if (_activeTab == 1)
{
@* ── Register Form ── *@
@if (TenantInfo.IsTenant)
{
@* ── Employee Register Form ── *@
<MudStack Spacing="3">
@if (_error != null)
{
@@ -131,19 +219,179 @@
</EditForm>
</MudStack>
}
else
{
@* ── Single User Register Form ── *@
<MudStack Spacing="3">
@if (_error != null)
{
<MudAlert Severity="Severity.Error" Dense="true">@_error</MudAlert>
}
<EditForm Model="@_registerModel" OnValidSubmit="HandleSingleUserRegister">
<MudStack Spacing="3">
<MudTextField T="string"
Label="Benutzername"
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Person"
@bind-Value="_registerModel.Username"
Required="true"
HelperText="Mindestens 3 Zeichen" />
<MudTextField T="string"
Label="Passwort"
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Lock"
InputType="InputType.Password"
@bind-Value="_registerModel.Password"
Required="true"
HelperText="Mindestens 6 Zeichen" />
<MudTextField T="string"
Label="Passwort wiederholen"
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Lock"
InputType="InputType.Password"
@bind-Value="_confirmPassword"
Required="true" />
<input type="text" style="display: none;" tabindex="-1" autocomplete="off" @bind="_honeypot" />
<MudCheckBox @bind-Value="_acceptAgb" Color="Color.Secondary" Class="mt-1">
<MudText Typo="Typo.body2">Ich akzeptiere die <a href="/agb" target="_blank" style="color: var(--mud-palette-secondary); text-decoration: underline; font-weight: 600;">AGB</a>.</MudText>
</MudCheckBox>
<MudButton ButtonType="ButtonType.Submit"
Variant="Variant.Filled"
Color="Color.Secondary"
FullWidth="true"
Size="Size.Large"
StartIcon="@Icons.Material.Filled.PersonAdd"
Class="mt-2"
Disabled="_loading || !_acceptAgb">
@if (_loading)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
}
Konto erstellen
</MudButton>
</MudStack>
</EditForm>
</MudStack>
}
}
else if (_activeTab == 2)
{
@* ── Firm Register Form ── *@
<MudStack Spacing="3">
@if (_error != null)
{
<MudAlert Severity="Severity.Error" Dense="true">@_error</MudAlert>
}
<EditForm Model="@_firmModel" OnValidSubmit="HandleRegisterFirm">
<DataAnnotationsValidator />
<MudStack Spacing="3">
<MudTextField T="string"
Label="Firmenname (z.B. Acme GmbH)"
Variant="Variant.Outlined"
@bind-Value="_firmModel.CompanyName"
Required="true" />
<MudTextField T="string"
Label="Subdomain"
Variant="Variant.Outlined"
HelperText="Nur Kleinbuchstaben, Zahlen & Bindestriche (z.B. acme)"
@bind-Value="_firmModel.Subdomain"
Required="true"
Adornment="Adornment.End"
AdornmentText="@GetDomainSuffix()" />
<MudDivider Class="my-2" />
<MudText Typo="Typo.subtitle2" Style="font-weight:700;">Administrator-Konto</MudText>
<MudTextField T="string"
Label="Benutzername"
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Person"
@bind-Value="_firmModel.AdminUsername"
Required="true"
HelperText="Mindestens 3 Zeichen" />
<MudTextField T="string"
Label="Passwort"
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Lock"
InputType="InputType.Password"
@bind-Value="_firmModel.AdminPassword"
Required="true"
HelperText="Mindestens 6 Zeichen" />
<MudTextField T="string"
Label="Passwort wiederholen"
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Lock"
InputType="InputType.Password"
@bind-Value="_firmModel.ConfirmPassword"
Required="true" />
<MudCheckBox @bind-Value="_acceptAgb" Color="Color.Secondary" Class="mt-1">
<MudText Typo="Typo.body2">Ich akzeptiere die <a href="/agb" target="_blank" style="color: var(--mud-palette-secondary); text-decoration: underline; font-weight: 600;">AGB</a>.</MudText>
</MudCheckBox>
<MudButton ButtonType="ButtonType.Submit"
Variant="Variant.Filled"
Color="Color.Secondary"
FullWidth="true"
Size="Size.Large"
StartIcon="@Icons.Material.Filled.Business"
Class="mt-2"
Disabled="_loading || !_acceptAgb">
@if (_loading)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
}
Firma registrieren
</MudButton>
</MudStack>
</EditForm>
</MudStack>
}
@if (!TenantInfo.IsTenant && _activeTab != 2)
{
<MudDivider Class="my-4" />
<MudStack AlignItems="AlignItems.Center">
<MudText Typo="Typo.body2" Color="Color.Secondary">
Sie möchten Timetracker für Ihr Unternehmen nutzen?
</MudText>
<MudButton Variant="Variant.Text" Color="Color.Primary" OnClick="@(() => SetTab(2))" StartIcon="@Icons.Material.Filled.Business">
Eigene Firma registrieren
</MudButton>
</MudStack>
}
}
</MudPaper>
</MudStack>
</MudContainer>
}
@code {
[PersistentState(AllowUpdates = true)]
public TenantInfo? TenantInfo { get; set; }
private int _activeTab = 0;
private string? _error;
private bool _loading;
private string _honeypot = "";
private bool _acceptAgb;
private bool _firmRegisteredSuccessfully;
private string _confirmPassword = "";
private readonly AuthModel _loginModel = new();
private readonly AuthModel _registerModel = new();
private readonly FirmModel _firmModel = new();
[SupplyParameterFromQuery(Name = "error")]
public string? ErrorParam { get; set; }
@@ -151,6 +399,23 @@
[SupplyParameterFromQuery(Name = "tab")]
public string? TabParam { get; set; }
protected override async Task OnInitializedAsync()
{
if (TenantInfo is null)
{
try
{
TenantInfo = await Http.GetFromJsonAsync<TenantInfo>("api/tenant/current");
}
catch (Exception ex)
{
Console.WriteLine($"Fehler beim Abrufen der TenantInfo: {ex.Message}");
// Fallback
TenantInfo = new TenantInfo { IsTenant = false };
}
}
}
protected override void OnParametersSet()
{
_error = ErrorParam switch
@@ -168,6 +433,35 @@
_error = null;
}
private string GetPageTitle()
{
if (TenantInfo == null) return "Lade…";
return TenantInfo.IsTenant ? $"Anmelden bei {TenantInfo.Name} Timetracker" : "Anmelden Timetracker SaaS";
}
private string GetDomainSuffix()
{
var uri = new Uri(Nav.BaseUri);
var host = uri.Host;
return $".{host}";
}
private string GetTenantUrl()
{
var uri = new Uri(Nav.BaseUri);
var scheme = uri.Scheme;
var port = uri.Port == 80 || uri.Port == 443 ? "" : $":{uri.Port}";
var host = uri.Host;
return $"{scheme}://{_firmModel.Subdomain.ToLower().Trim()}.{host}{port}";
}
private void ResetRegistrationFlow()
{
_firmRegisteredSuccessfully = false;
_activeTab = 0;
_error = null;
}
private async Task HandleLogin()
{
_loading = true;
@@ -177,7 +471,7 @@
var user = await AuthService.LoginAsync(_loginModel.Username, _loginModel.Password);
if (user != null)
{
Nav.NavigateTo("/", forceLoad: true); // forceLoad forces state update/re-render of the root app
Nav.NavigateTo("/", forceLoad: true);
}
else
{
@@ -226,9 +520,100 @@
}
}
private async Task HandleSingleUserRegister()
{
if (!_acceptAgb)
{
_error = "Du musst die AGB akzeptieren.";
return;
}
if (_registerModel.Password != _confirmPassword)
{
_error = "Passwörter stimmen nicht überein.";
return;
}
_loading = true;
_error = null;
try
{
var (user, error) = await AuthService.RegisterAsync(_registerModel.Username, _registerModel.Password, _honeypot);
if (user != null)
{
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "showOnboarding", "true");
Nav.NavigateTo("/", forceLoad: true);
}
else
{
_error = error ?? "Registrierung fehlgeschlagen.";
}
}
catch (Exception ex)
{
_error = $"Registrierungs Fehler: {ex.Message}";
}
finally
{
_loading = false;
}
}
private async Task HandleRegisterFirm()
{
if (!_acceptAgb)
{
_error = "Du musst die AGB akzeptieren.";
return;
}
if (_firmModel.AdminPassword != _firmModel.ConfirmPassword)
{
_error = "Passwörter stimmen nicht überein.";
return;
}
_loading = true;
_error = null;
try
{
var req = new RegisterFirmRequest(
_firmModel.CompanyName,
_firmModel.Subdomain,
_firmModel.AdminUsername,
_firmModel.AdminPassword
);
var (tenant, error) = await AuthService.RegisterFirmAsync(req);
if (tenant != null)
{
_firmRegisteredSuccessfully = true;
}
else
{
_error = error ?? "Registrierung der Firma fehlgeschlagen.";
}
}
catch (Exception ex)
{
_error = $"Registrierungs Fehler: {ex.Message}";
}
finally
{
_loading = false;
}
}
private class AuthModel
{
public string Username { get; set; } = "";
public string Password { get; set; } = "";
}
private class FirmModel
{
public string CompanyName { get; set; } = "";
public string Subdomain { get; set; } = "";
public string AdminUsername { get; set; } = "";
public string AdminPassword { get; set; } = "";
public string ConfirmPassword { get; set; } = "";
}
}
@@ -4,6 +4,7 @@
@inject ITimetrackerService TrackerService
@inject IHolidayService HolidayService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
<PageTitle>@_deCulture.DateTimeFormat.GetMonthName(_month) @_year Monatsübersicht Timetracker</PageTitle>
@@ -19,29 +20,36 @@ else
<MudStack Spacing="3">
@* ── Monats-Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: #1E293B; color: white;">
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
Style="color:white" Size="Size.Large" OnClick="PrevMonth" />
Style="color: var(--mud-palette-primary-text)" Size="Size.Large" OnClick="PrevMonth" />
<MudStack Spacing="0" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5" Style="color:white; font-weight:700; letter-spacing:0.5px">
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700; letter-spacing:0.5px">
@_deCulture.DateTimeFormat.GetMonthName(_month) @_year
</MudText>
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72">
@_subLabel
</MudText>
</MudStack>
<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">
<MudIconButton Icon="@Icons.Material.Filled.Download"
Style="color: var(--mud-palette-primary-text)" OnClick="ExportMonthExcel" />
</MudTooltip>
@if (!IsCurrentMonth)
{
<MudButton Variant="Variant.Text" Style="color:white"
<MudButton Variant="Variant.Text" Style="color: var(--mud-palette-primary-text)"
OnClick="GoToCurrentMonth" Size="Size.Small">
Heute
</MudButton>
}
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
Style="color:white" Size="Size.Large" OnClick="NextMonth" />
Style="color: var(--mud-palette-primary-text)" Size="Size.Large" OnClick="NextMonth" />
</MudStack>
</MudStack>
</MudPaper>
@@ -95,7 +103,7 @@ else
@* ── Monatszusammenfassung ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: linear-gradient(135deg, rgba(14,165,233,0.08) 0%, rgba(14,165,233,0.02) 100%); border-left: 6px solid #0EA5E9;">
Style="background: linear-gradient(135deg, color-mix(in srgb, var(--mud-palette-primary) 8%, transparent) 0%, color-mix(in srgb, var(--mud-palette-primary) 2%, transparent) 100%); border-left: 6px solid var(--mud-palette-primary);">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="mb-4">
<MudIcon Icon="@Icons.Material.Filled.CalendarViewMonth" Color="Color.Primary" />
<MudText Typo="Typo.h6" Style="font-weight:700">Monatszusammenfassung</MudText>
@@ -151,6 +159,33 @@ else
@code {
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 int _userId;
private int _year = DateTime.Today.Year;
@@ -170,14 +205,42 @@ else
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 claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
if (claim == null) return;
_userId = int.Parse(claim.Value);
if (PersistentSettings == null || PersistentDays == null)
{
_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()
{
@@ -269,6 +332,16 @@ else
await LoadMonth();
}
private void ExportMonthExcel()
{
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)
{
var neg = ts < TimeSpan.Zero;
@@ -308,7 +381,7 @@ else
else Chip("Ausstehend", "#CFD8DC");
};
private sealed class MonthDayVm
public sealed class MonthDayVm
{
public DateOnly Date { get; set; }
public TimeOnly? StartTime { get; set; }
@@ -22,13 +22,12 @@ else
<MudStack Spacing="4">
@* ── Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: #1E293B; color:white;">
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
<MudIcon Icon="@Icons.Material.Filled.AccountCircle" Style="color:white; font-size:2.5rem" />
<MudIcon Icon="@Icons.Material.Filled.AccountCircle" Style="color: var(--mud-palette-primary-text); font-size:2.5rem" />
<MudStack Spacing="0">
<MudText Typo="Typo.h5" Style="color:white; font-weight:700">Mein Profil</MudText>
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700">Mein Profil</MudText>
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72;">
Persönliche Details verwalten und Account sichern
</MudText>
</MudStack>
@@ -164,6 +163,21 @@ else
}
@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 int _userId;
private string _username = "";
@@ -185,19 +199,39 @@ else
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 claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
if (claim == null) return;
_userId = int.Parse(claim.Value);
if (PersistentSettings == null)
{
_username = authState.User.Identity?.Name ?? "";
_newUsername = _username;
_isAdmin = _username.Equals("marc", StringComparison.OrdinalIgnoreCase);
_settings = await TrackerService.GetSettingsAsync(_userId);
_overtime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings);
PersistentSettings = _settings;
PersistentUsername = _username;
PersistentNewUsername = _newUsername;
PersistentIsAdmin = _isAdmin;
PersistentOvertime = _overtime;
_loading = false;
}
}
private string GetInitials(string? name)
{
@@ -0,0 +1,29 @@
@namespace timetracker.Client.Components.Pages
@inject ISnackbar Snackbar
<MudDialog>
<DialogContent>
<MudText Class="mb-4">Geben Sie das neue Passwort für den Benutzer <strong>@Username</strong> ein.</MudText>
<MudTextField @bind-Value="_newPassword"
Label="Neues Passwort"
InputType="InputType.Password"
Variant="Variant.Outlined"
AutoFocus="true"
Required="true"
HelperText="Mindestens 6 Zeichen" />
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Abbrechen</MudButton>
<MudButton Color="Color.Warning" Variant="Variant.Filled" OnClick="Submit" Disabled="@(string.IsNullOrEmpty(_newPassword) || _newPassword.Length < 6)">Passwort zurücksetzen</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!;
[Parameter] public string Username { get; set; } = "";
private string _newPassword = "";
private void Submit() => MudDialog.Close(DialogResult.Ok(_newPassword));
private void Cancel() => MudDialog.Cancel();
}
@@ -7,6 +7,7 @@
@inject AuthenticationStateProvider AuthStateProvider
@inject IJSRuntime JSRuntime
@inject NavigationManager Nav
@inject HttpClient Http
<PageTitle>Einstellungen Timetracker</PageTitle>
@@ -22,13 +23,12 @@ else
<MudStack Spacing="4">
@* ── Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: #1E293B; color:white;">
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
<MudIcon Icon="@Icons.Material.Filled.Settings" Style="color:white; font-size:2rem" />
<MudIcon Icon="@Icons.Material.Filled.Settings" Style="color: var(--mud-palette-primary-text); font-size:2rem" />
<MudStack Spacing="0">
<MudText Typo="Typo.h5" Style="color:white; font-weight:700">Einstellungen</MudText>
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700">Einstellungen</MudText>
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72">
Arbeitszeit, Arbeitstage und Urlaub konfigurieren
</MudText>
</MudStack>
@@ -217,6 +217,52 @@ else
</MudCard>
</MudItem>
@if (_isTenantAdmin)
{
@* ── Firmen-Einstellungen (Branding) ── *@
<MudItem xs="12" md="6">
<MudCard Elevation="3" Class="rounded-xl h-100">
<MudCardHeader>
<CardHeaderContent>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.Business" Color="Color.Primary" />
<MudText Typo="Typo.h6" Style="font-weight:600">Firmen-Einstellungen</MudText>
</MudStack>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudStack Spacing="4">
<MudTextField @bind-Value="_tenantName"
Label="Firmenname"
Variant="Variant.Outlined"
HelperText="Der offizielle Name deiner Firma" />
<MudStack Row="true" Spacing="4">
<MudColorPicker @bind-Text="_tenantPrimaryColor"
Label="Primärfarbe"
Variant="Variant.Outlined"
Style="@($"color: {_tenantPrimaryColor};")"
ColorPickerMode="ColorPickerMode.HEX" />
<MudColorPicker @bind-Text="_tenantSecondaryColor"
Label="Sekundärfarbe"
Variant="Variant.Outlined"
Style="@($"color: {_tenantSecondaryColor};")"
ColorPickerMode="ColorPickerMode.HEX" />
</MudStack>
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Save"
OnClick="SaveTenantSettings"
Style="max-width:250px;">
Firma speichern
</MudButton>
</MudStack>
</MudCardContent>
</MudCard>
</MudItem>
}
</MudGrid>
@* ── Speichern-Button ── *@
@@ -465,8 +511,34 @@ else
@code {
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 int _userId;
private bool _isTenantAdmin;
private TenantInfo? _tenantInfo;
private string _tenantName = "";
private string _tenantPrimaryColor = "#0EA5E9";
private string _tenantSecondaryColor = "#0EA5E9";
private DateTime? _flexStartDate
{
get => _settings?.FlexTimeStartDate?.ToDateTime(TimeOnly.MinValue);
@@ -520,17 +592,63 @@ else
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 claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
if (claim == null) return;
_userId = int.Parse(claim.Value);
if (PersistentSettings != null)
{
_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)
{
try
{
_tenantInfo = await Http.GetFromJsonAsync<TenantInfo>("api/tenant/current");
if (_tenantInfo != null)
{
_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);
}
}
var loadVacationsTask = LoadVacations();
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;
}
}
private async Task LoadVacations()
@@ -622,6 +740,35 @@ else
}
}
private async Task SaveTenantSettings()
{
if (string.IsNullOrWhiteSpace(_tenantName))
{
Snackbar.Add("Bitte einen Firmennamen eingeben.", Severity.Warning);
return;
}
var dto = new TenantSettingsDto
{
Name = _tenantName.Trim(),
PrimaryColor = _tenantPrimaryColor,
SecondaryColor = _tenantSecondaryColor
};
var response = await Http.PostAsJsonAsync("api/tracker/tenant/settings", dto);
if (response.IsSuccessStatusCode)
{
Snackbar.Add("Firmen-Einstellungen gespeichert. Lade Seite neu...", Severity.Success);
await Task.Delay(1000);
Nav.NavigateTo(Nav.Uri, forceLoad: true);
}
else
{
var err = await response.Content.ReadAsStringAsync();
Snackbar.Add($"Fehler beim Speichern: {err}", Severity.Error);
}
}
private static string FormatHours(double hours)
{
var ts = TimeSpan.FromHours(hours);
+156 -17
View File
@@ -3,6 +3,7 @@
@attribute [Authorize]
@inject ITimetrackerService TrackerService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
@using System.Security.Claims
@using timetracker.Shared
@@ -20,12 +21,12 @@ else
<MudStack Spacing="4">
@* ── Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl" Style="background: #1E293B; color: white;">
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
<MudIcon Icon="@Icons.Material.Filled.BarChart" Style="color:white; font-size:2.2rem" />
<MudIcon Icon="@Icons.Material.Filled.BarChart" Style="color: var(--mud-palette-primary-text); font-size:2.2rem" />
<MudStack Spacing="0">
<MudText Typo="Typo.h5" Style="color:white; font-weight:700">Statistiken</MudText>
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">Auswertung deiner Arbeitsleistung und Gleitzeitkonto</MudText>
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700">Statistiken</MudText>
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72">Auswertung deiner Arbeitsleistung und Gleitzeitkonto</MudText>
</MudStack>
</MudStack>
</MudPaper>
@@ -46,7 +47,7 @@ else
<div style="position:relative; display:inline-flex;">
<MudProgressCircular Value="@pct" Color="Color.Secondary" Size="Size.Large" StrokeWidth="6" Style="height:120px; width:120px;" />
<div style="position:absolute; top:0; left:0; bottom:0; right:0; display:flex; align-items:center; justify-content:center; flex-direction:column;">
<MudText Typo="Typo.h5" Style="font-weight:800; color:#0F172A;">@displayPct%</MudText>
<MudText Typo="Typo.h5" Style="font-weight:800; color: var(--mud-palette-text-primary);">@displayPct%</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Erreicht</MudText>
</div>
</div>
@@ -54,7 +55,7 @@ else
<MudStack Spacing="1" Style="width:100%;" Class="mt-2">
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Color="Color.Secondary">Arbeitszeit (Ist):</MudText>
<MudText Typo="Typo.body2" Style="font-weight:700; color:#0F172A">@FormatHours(_weekWorkedHours) Std.</MudText>
<MudText Typo="Typo.body2" Style="font-weight:700; color: var(--mud-palette-text-primary)">@FormatHours(_weekWorkedHours) Std.</MudText>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Color="Color.Secondary">Wochensoll (Soll):</MudText>
@@ -68,7 +69,7 @@ else
@* ── Card 2: Wochensaldo ── *@
<MudItem xs="12" sm="6" md="4">
<MudCard Elevation="3" Class="rounded-xl" Style="height:100%; border-left: 6px solid #0EA5E9;">
<MudCard Elevation="3" Class="rounded-xl" Style="height:100%; border-left: 6px solid var(--mud-palette-primary);">
<MudCardContent>
<MudStack Spacing="2">
<MudText Typo="Typo.subtitle1" Style="font-weight:700; color:#475569">Wochensaldo</MudText>
@@ -115,7 +116,7 @@ else
<MudCard Elevation="3" Class="rounded-xl">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6" Style="font-weight:700; color:#0F172A">Arbeitszeitverteilung diese Woche</MudText>
<MudText Typo="Typo.h6" Style="font-weight:700; color: var(--mud-palette-text-primary)">Arbeitszeitverteilung diese Woche</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Geleistete Nettoarbeitsstunden pro Wochentag</MudText>
</CardHeaderContent>
</MudCardHeader>
@@ -148,8 +149,8 @@ else
{
double val = (_maxHoursValue / 4.0) * i;
double yPos = plotBottom - (val / _maxHoursValue) * plotHeight;
<line x1="@plotLeft" y1="@yPos" x2="@plotRight" y2="@yPos" stroke="#E2E8F0" stroke-width="1" stroke-dasharray="2" />
@((MarkupString)$"<text x=\"{plotLeft - 8}\" y=\"{yPos + 4}\" fill=\"#64748B\" font-size=\"11\" font-weight=\"500\" text-anchor=\"end\">{val:F1}h</text>")
<line x1="@F(plotLeft)" y1="@F(yPos)" x2="@F(plotRight)" y2="@F(yPos)" stroke="#E2E8F0" stroke-width="1" stroke-dasharray="2" />
@((MarkupString)$"<text x=\"{F(plotLeft - 8)}\" y=\"{F(yPos + 4)}\" fill=\"#64748B\" font-size=\"11\" font-weight=\"500\" text-anchor=\"end\">{val:F1}h</text>")
}
<!-- Bars and Labels -->
@@ -163,7 +164,7 @@ else
string fillCol = "#94A3B8"; // Default light slate
if (d.IsToday)
{
fillCol = "#0EA5E9"; // Sky Blue for today
fillCol = "var(--mud-palette-secondary)"; // Brand color for today
}
else if (d.WorkedHours >= d.TargetHours && d.TargetHours > 0)
{
@@ -183,7 +184,7 @@ else
}
<!-- Render Rect with hover tooltip -->
<rect x="@xPos" y="@(d.WorkedHours > 0 ? yPos : plotBottom - 4)" width="@barWidth" height="@(d.WorkedHours > 0 ? bHeight : 4)"
<rect x="@F(xPos)" y="@F(d.WorkedHours > 0 ? yPos : plotBottom - 4)" width="@F(barWidth)" height="@F(d.WorkedHours > 0 ? bHeight : 4)"
rx="6" fill="@fillCol" class="chart-bar">
<title>@d.DayName: @FormatHours(d.WorkedHours) Std. (Soll: @FormatHours(d.TargetHours) Std.)</title>
</rect>
@@ -191,19 +192,19 @@ else
<!-- Value Label above bar -->
@if (d.WorkedHours > 0)
{
@((MarkupString)$"<text x=\"{xPos + barWidth / 2}\" y=\"{yPos - 6}\" fill=\"#0F172A\" font-size=\"11\" font-weight=\"700\" text-anchor=\"middle\">{FormatHours(d.WorkedHours)}</text>")
@((MarkupString)$"<text x=\"{F(xPos + barWidth / 2)}\" y=\"{F(yPos - 6)}\" fill=\"var(--mud-palette-text-primary)\" font-size=\"11\" font-weight=\"700\" text-anchor=\"middle\">{FormatHours(d.WorkedHours)}</text>")
}
<!-- Wochentag Text -->
@((MarkupString)$"<text x=\"{xPos + barWidth / 2}\" y=\"{plotBottom + 20}\" fill=\"#64748B\" font-size=\"11\" font-weight=\"700\" text-anchor=\"middle\">{d.DayShortName}</text>")
@((MarkupString)$"<text x=\"{F(xPos + barWidth / 2)}\" y=\"{F(plotBottom + 20)}\" fill=\"#64748B\" font-size=\"11\" font-weight=\"700\" text-anchor=\"middle\">{d.DayShortName}</text>")
}
<!-- Sollzeit Target Line (Only if target hours > 0) -->
@if (_settings.DailyTargetHours > 0)
{
double yTarget = plotBottom - (_settings.DailyTargetHours / _maxHoursValue) * plotHeight;
<line x1="@plotLeft" y1="@yTarget" x2="@plotRight" y2="@yTarget" stroke="#EF4444" stroke-width="2" stroke-dasharray="4" style="opacity: 0.75;" />
@((MarkupString)$"<text x=\"{plotRight - 10}\" y=\"{yTarget - 6}\" fill=\"#EF4444\" font-size=\"10\" font-weight=\"700\" text-anchor=\"end\">Soll ({_settings.DailyTargetHours} h)</text>")
<line x1="@F(plotLeft)" y1="@F(yTarget)" x2="@F(plotRight)" y2="@F(yTarget)" stroke="#EF4444" stroke-width="2" stroke-dasharray="4" style="opacity: 0.75;" />
@((MarkupString)$"<text x=\"{F(plotRight - 10)}\" y=\"{F(yTarget - 6)}\" fill=\"#EF4444\" font-size=\"10\" font-weight=\"700\" text-anchor=\"end\">Soll ({_settings.DailyTargetHours} h)</text>")
}
</svg>
</div>
@@ -211,6 +212,75 @@ else
</MudCard>
</MudItem>
@* ── Excel Export ── *@
<MudItem xs="12">
<MudCard Elevation="3" Class="rounded-xl">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6" Style="font-weight:700; color: var(--mud-palette-text-primary)">Excel-Datenexport</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Lade deine erfassten Arbeitszeiten in verschiedenen Zeiträumen als formatierte Excel-Datei herunter.</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudGrid Spacing="3">
@* Wochenexport *@
<MudItem xs="12" sm="4">
<MudStack Spacing="3">
<MudText Typo="Typo.subtitle2" Style="font-weight:700">Wochenexport</MudText>
<MudDatePicker Label="Woche wählen" @bind-Date="_exportWeekDate" PickerVariant="PickerVariant.Dialog" Variant="Variant.Outlined" Color="Color.Primary" />
<MudButton StartIcon="@Icons.Material.Filled.Download" Variant="Variant.Filled" Color="Color.Primary" OnClick="ExportWeek" FullWidth="true">
Woche exportieren
</MudButton>
</MudStack>
</MudItem>
@* Monatsexport *@
<MudItem xs="12" sm="4">
<MudStack Spacing="3">
<MudText Typo="Typo.subtitle2" Style="font-weight:700">Monatsexport</MudText>
<MudStack Row="true" Spacing="2">
<MudSelect T="int" Label="Monat" @bind-Value="_exportMonth" Variant="Variant.Outlined">
@for (int m = 1; m <= 12; m++)
{
var monthVal = m;
<MudSelectItem Value="@monthVal">@_deCulture.DateTimeFormat.GetMonthName(monthVal)</MudSelectItem>
}
</MudSelect>
<MudSelect T="int" Label="Jahr" @bind-Value="_exportMonthYear" Variant="Variant.Outlined">
@for (int y = DateTime.Today.Year - 5; y <= DateTime.Today.Year + 1; y++)
{
var yearVal = y;
<MudSelectItem Value="@yearVal">@yearVal</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudButton StartIcon="@Icons.Material.Filled.Download" Variant="Variant.Filled" Color="Color.Primary" OnClick="ExportMonth" FullWidth="true">
Monat exportieren
</MudButton>
</MudStack>
</MudItem>
@* Jahresexport *@
<MudItem xs="12" sm="4">
<MudStack Spacing="3">
<MudText Typo="Typo.subtitle2" Style="font-weight:700">Jahresexport</MudText>
<MudSelect T="int" Label="Jahr" @bind-Value="_exportYear" Variant="Variant.Outlined">
@for (int y = DateTime.Today.Year - 5; y <= DateTime.Today.Year + 1; y++)
{
var yearVal = y;
<MudSelectItem Value="@yearVal">@yearVal</MudSelectItem>
}
</MudSelect>
<MudButton StartIcon="@Icons.Material.Filled.Download" Variant="Variant.Filled" Color="Color.Primary" OnClick="ExportYear" FullWidth="true">
Jahr exportieren
</MudButton>
</MudStack>
</MudItem>
</MudGrid>
</MudCardContent>
</MudCard>
</MudItem>
@* ── Diagnostics Panel ── *@
<MudItem xs="12">
<MudExpansionPanels>
@@ -254,6 +324,27 @@ else
}
@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 int _userId;
private AppSettings _settings = new();
@@ -264,19 +355,40 @@ else
private double _weekOvertimeHours;
private double _maxHoursValue = 10.0;
// Excel Export variables
private DateTime? _exportWeekDate = DateTime.Today;
private int _exportMonth = DateTime.Today.Month;
private int _exportMonthYear = DateTime.Today.Year;
private int _exportYear = DateTime.Today.Year;
// Debug variables
private int _rawDbDaysCount = 0;
private Dictionary<DateOnly, string> _dbMatchStatus = new();
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
private static string F(double value) => value.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
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 claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
if (claim == null) return;
_userId = int.Parse(claim.Value);
if (PersistentSettings == null || PersistentDays == null)
{
_settings = await TrackerService.GetSettingsAsync(_userId);
var today = DateOnly.FromDateTime(DateTime.Today);
@@ -349,8 +461,16 @@ else
var maxWorked = _days.Max(d => d.WorkedHours);
_maxHoursValue = Math.Max(10.0, Math.Max(maxWorked, _settings.DailyTargetHours));
PersistentSettings = _settings;
PersistentDays = _days;
PersistentTotalOvertime = _totalOvertime;
PersistentWeekWorkedHours = _weekWorkedHours;
PersistentWeekTargetHours = _weekTargetHours;
PersistentWeekOvertimeHours = _weekOvertimeHours;
PersistentMaxHoursValue = _maxHoursValue;
_loading = false;
}
}
private static DateOnly GetMonday(DateOnly date)
{
@@ -372,7 +492,26 @@ else
return $"{prefix}{(int)abs.TotalHours}:{abs.Minutes:D2}";
}
private sealed class DayStat
private void ExportWeek()
{
if (_exportWeekDate.HasValue)
{
var date = DateOnly.FromDateTime(_exportWeekDate.Value);
NavigationManager.NavigateTo($"api/tracker/export/week?monday={date:yyyy-MM-dd}", forceLoad: true);
}
}
private void ExportMonth()
{
NavigationManager.NavigateTo($"api/tracker/export/month?year={_exportMonthYear}&month={_exportMonth}", forceLoad: true);
}
private void ExportYear()
{
NavigationManager.NavigateTo($"api/tracker/export/year?year={_exportYear}", forceLoad: true);
}
public sealed class DayStat
{
public DateOnly Date { get; set; }
public string DayName { get; set; } = "";
@@ -0,0 +1,279 @@
@page "/superadmin"
@rendermode InteractiveWebAssembly
@attribute [Authorize(Policy = "AdminOnly")]
@using System.Net.Http.Json
@inject HttpClient Http
@inject ISnackbar Snackbar
@inject IDialogService DialogService
<PageTitle>Firmenverwaltung Timetracker SaaS</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 Firmen und Mandanten…</MudText>
</MudStack>
}
else
{
<MudStack Spacing="4">
@* ── Header Card ── *@
<MudPaper Elevation="4" Class="pa-6 rounded-xl"
Style="background: linear-gradient(135deg, var(--mud-palette-primary-darken) 0%, var(--mud-palette-primary) 100%); color: var(--mud-palette-primary-text);">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="4">
<MudIcon Icon="@Icons.Material.Filled.Business" Style="color: var(--mud-palette-secondary); font-size:3rem" />
<MudStack Spacing="0">
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:800">SuperAdmin Dashboard</MudText>
<MudText Typo="Typo.body2" Style="color: var(--mud-palette-primary-text); opacity: 0.72">
@_tenants.Count registrierte Firmen im System
</MudText>
</MudStack>
</MudStack>
</MudPaper>
@* ── Tenants Table ── *@
<MudCard Elevation="3" Class="rounded-xl overflow-hidden">
<MudCardContent Class="pa-0">
<MudTable Items="_tenants" Hover="true" Striped="true" Elevation="0" SortLabel="Sortieren">
<HeaderContent>
<MudTh Style="width: 50px;" />
<MudTh><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.Id)">ID</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.Name)">Name</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.Subdomain)">Subdomain</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.CreatedAt)">Erstellt am</MudTableSortLabel></MudTh>
<MudTh Style="text-align:center"><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.UserCount)">Benutzer</MudTableSortLabel></MudTh>
<MudTh Style="text-align:center"><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.WorkDaysCount)">Arbeitstage</MudTableSortLabel></MudTh>
<MudTh Style="text-align:center">Status</MudTh>
<MudTh Style="text-align:right">Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudIconButton Icon="@(context.ShowDetails ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)"
Size="Size.Small"
OnClick="@(() => context.ShowDetails = !context.ShowDetails)" />
</MudTd>
<MudTd DataLabel="ID">
<MudText Typo="Typo.body2" Color="Color.Secondary">@context.Id</MudText>
</MudTd>
<MudTd DataLabel="Name">
<MudText Typo="Typo.body1" Style="font-weight:600">@context.Name</MudText>
</MudTd>
<MudTd DataLabel="Subdomain">
<MudChip T="string" Color="Color.Primary" Variant="Variant.Outlined" Size="Size.Small">
@context.Subdomain
</MudChip>
</MudTd>
<MudTd DataLabel="Erstellt am">
<MudText Typo="Typo.body2">@context.CreatedAt.ToLocalTime().ToString("dd.MM.yyyy HH:mm")</MudText>
</MudTd>
<MudTd DataLabel="Benutzer" Style="text-align:center">
<MudBadge Content="@context.UserCount" Color="Color.Default" Overlap="false" Class="mx-2" />
</MudTd>
<MudTd DataLabel="Arbeitstage" Style="text-align:center">
<MudText Typo="Typo.body2">@context.WorkDaysCount</MudText>
</MudTd>
<MudTd DataLabel="Status" Style="text-align:center">
@if (context.IsApproved)
{
<MudChip T="string" Color="Color.Success" Size="Size.Small" Icon="@Icons.Material.Filled.Check">Aktiv</MudChip>
}
else
{
<MudChip T="string" Color="Color.Warning" Size="Size.Small" Icon="@Icons.Material.Filled.HourglassEmpty">Ausstehend</MudChip>
}
</MudTd>
<MudTd Style="text-align:right">
@if (!context.IsApproved)
{
<MudButton Variant="Variant.Filled"
Color="Color.Success"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.CheckCircle"
OnClick="@(() => ApproveTenant(context))"
Class="mr-1">
Freigeben
</MudButton>
}
else
{
<MudButton Variant="Variant.Outlined"
Color="Color.Warning"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.Block"
OnClick="@(() => ToggleActiveTenant(context))"
Class="mr-1">
Sperren
</MudButton>
}
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
Size="Size.Small"
OnClick="@(() => DeleteTenant(context))" />
</MudTd>
</RowTemplate>
<ChildRowContent>
@if (context.ShowDetails)
{
<MudTr>
<td colspan="9" style="background-color: var(--mud-palette-background-grey); padding: 16px;">
<MudPaper Elevation="1" Class="pa-4 rounded-lg">
<MudText Typo="Typo.subtitle2" Class="mb-3" Style="font-weight: 700;">
Benutzer für Firma: @context.Name
</MudText>
@if (context.Users == null || context.Users.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">Keine Benutzer in dieser Firma registriert.</MudText>
}
else
{
<MudGrid Spacing="2">
@foreach (var user in context.Users)
{
<MudItem xs="12" sm="6" md="4">
<MudPaper Outlined="true" Class="pa-3 rounded-lg d-flex align-center justify-space-between" Style="background: white;">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudAvatar Color="user.IsTenantAdmin ? Color.Primary : Color.Default" Size="Size.Small">
@user.Username.Substring(0, Math.Min(2, user.Username.Length)).ToUpper()
</MudAvatar>
<MudStack Spacing="0">
<MudText Typo="Typo.body2" Style="font-weight: 600;">@user.Username</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">ID: @user.Id</MudText>
</MudStack>
</MudStack>
@if (user.IsTenantAdmin)
{
<MudChip T="string" Color="Color.Primary" Size="Size.Small" Variant="Variant.Text" Icon="@Icons.Material.Filled.AdminPanelSettings">Admin</MudChip>
}
else
{
<MudChip T="string" Color="Color.Default" Size="Size.Small" Variant="Variant.Text" Icon="@Icons.Material.Filled.Person">Mitarbeiter</MudChip>
}
</MudPaper>
</MudItem>
}
</MudGrid>
}
</MudPaper>
</td>
</MudTr>
}
</ChildRowContent>
</MudTable>
</MudCardContent>
</MudCard>
</MudStack>
}
@code {
[PersistentState(AllowUpdates = true)]
public List<TenantStats>? PersistentTenants { get; set; }
private bool _loading = true;
private List<TenantStats> _tenants = [];
protected override async Task OnInitializedAsync()
{
if (PersistentTenants != null)
{
_tenants = PersistentTenants;
_loading = false;
}
else
{
await LoadTenants();
}
}
private async Task LoadTenants()
{
_loading = true;
try
{
_tenants = await Http.GetFromJsonAsync<List<TenantStats>>("api/superadmin/tenants") ?? [];
PersistentTenants = _tenants;
}
catch (Exception ex)
{
Snackbar.Add($"Fehler beim Laden der Firmen: {ex.Message}", Severity.Error);
}
finally
{
_loading = false;
}
}
private async Task ApproveTenant(TenantStats tenant)
{
try
{
var response = await Http.PostAsync($"api/superadmin/tenants/{tenant.Id}/approve", null);
if (response.IsSuccessStatusCode)
{
tenant.IsApproved = true;
Snackbar.Add($"Firma '{tenant.Name}' wurde erfolgreich freigegeben.", Severity.Success);
}
else
{
Snackbar.Add("Fehler bei der Freigabe.", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"Fehler: {ex.Message}", Severity.Error);
}
}
private async Task ToggleActiveTenant(TenantStats tenant)
{
try
{
var response = await Http.PostAsync($"api/superadmin/tenants/{tenant.Id}/toggle-active", null);
if (response.IsSuccessStatusCode)
{
tenant.IsApproved = !tenant.IsApproved;
var stateMsg = tenant.IsApproved ? "aktiviert" : "gesperrt";
Snackbar.Add($"Firma '{tenant.Name}' wurde {stateMsg}.", Severity.Info);
}
else
{
Snackbar.Add("Fehler beim Ändern des Status.", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"Fehler: {ex.Message}", Severity.Error);
}
}
private async Task DeleteTenant(TenantStats tenant)
{
bool? result = await DialogService.ShowMessageBoxAsync(
"Firma unwiderruflich löschen?",
$"Sind Sie sicher, dass Sie die Firma '{tenant.Name}' ({tenant.Subdomain}) inklusive ALLER zugehörigen Benutzer, Einstellungen und Zeiterfassungen unwiderruflich löschen möchten? Dieser Vorgang kann nicht rückgängig gemacht werden!",
yesText: "Löschen", cancelText: "Abbrechen");
if (result == true)
{
try
{
var response = await Http.DeleteAsync($"api/superadmin/tenants/{tenant.Id}");
if (response.IsSuccessStatusCode)
{
_tenants.Remove(tenant);
Snackbar.Add($"Firma '{tenant.Name}' wurde gelöscht.", Severity.Success);
}
else
{
var err = await response.Content.ReadAsStringAsync();
Snackbar.Add($"Fehler beim Löschen: {err}", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"Fehler: {ex.Message}", Severity.Error);
}
}
}
}
@@ -226,6 +226,27 @@ else
@code {
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 int _year = DateTime.Today.Year;
private AppSettings _settings = new();
@@ -238,14 +259,38 @@ else
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 claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
if (claim == null) return;
_userId = int.Parse(claim.Value);
if (PersistentSettings == null || PersistentSuggestions == null)
{
_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()
{
@@ -283,7 +328,7 @@ else
// ── Algorithmus ──────────────────────────────────────────────────────
private enum DayKind { Free, WorkAvailable, WorkTaken }
private sealed record Suggestion(
public sealed record Suggestion(
DateOnly SpanStart,
DateOnly SpanEnd,
List<DateOnly> VacationDaysToTake,
@@ -81,4 +81,38 @@ public class ClientAuthService : IAuthService
}
return null;
}
public async Task<(Tenant? Tenant, string? Error)> RegisterFirmAsync(RegisterFirmRequest req)
{
var response = await _http.PostAsJsonAsync("api/auth/register-firm", req);
if (response.IsSuccessStatusCode)
{
return (new Tenant { Name = req.CompanyName, Subdomain = req.Subdomain }, null);
}
else
{
var error = await response.Content.ReadAsStringAsync();
return (null, error);
}
}
public async Task<string?> AssignTenantAsync(int userId, int? tenantId)
{
var response = await _http.PutAsJsonAsync($"api/users/{userId}/assign-tenant", new AssignTenantRequest(tenantId));
if (!response.IsSuccessStatusCode)
{
return await response.Content.ReadAsStringAsync();
}
return null;
}
public async Task<string?> ResetPasswordAsync(int userId, string newPassword)
{
var response = await _http.PutAsJsonAsync($"api/users/{userId}/reset-password", new ResetPasswordRequest(newPassword));
if (!response.IsSuccessStatusCode)
{
return await response.Content.ReadAsStringAsync();
}
return null;
}
}
@@ -70,4 +70,14 @@ public class ClientTimetrackerService : ITimetrackerService
{
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.Net.Http.Json;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using timetracker.Shared;
@@ -8,12 +9,14 @@ namespace timetracker.Client.Services;
public class HostAuthenticationStateProvider : AuthenticationStateProvider
{
private readonly HttpClient _http;
private readonly PersistentComponentState _state;
private static readonly ClaimsPrincipal Anonymous = new(new ClaimsIdentity());
private ClaimsPrincipal? _currentUser;
public HostAuthenticationStateProvider(HttpClient http)
public HostAuthenticationStateProvider(HttpClient http, PersistentComponentState state)
{
_http = http;
_state = state;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
@@ -23,18 +26,40 @@ public class HostAuthenticationStateProvider : AuthenticationStateProvider
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
{
var response = await _http.GetAsync("api/auth/me");
if (response.IsSuccessStatusCode)
{
var userInfo = await response.Content.ReadFromJsonAsync<UserInfo>();
if (userInfo != null)
var apiUserInfo = await response.Content.ReadFromJsonAsync<UserInfo>();
if (apiUserInfo != null)
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, userInfo.Id.ToString()),
new Claim(ClaimTypes.Name, userInfo.Username)
new Claim(ClaimTypes.NameIdentifier, apiUserInfo.Id.ToString()),
new Claim(ClaimTypes.Name, apiUserInfo.Username),
new Claim("IsTenantAdmin", apiUserInfo.IsTenantAdmin ? "true" : "false"),
new Claim(ClaimTypes.Role, apiUserInfo.IsTenantAdmin ? "TenantAdmin" : "Employee")
};
var identity = new ClaimsIdentity(claims, "Cookie");
_currentUser = new ClaimsPrincipal(identity);
@@ -58,7 +83,9 @@ public class HostAuthenticationStateProvider : AuthenticationStateProvider
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, userInfo.Id.ToString()),
new Claim(ClaimTypes.Name, userInfo.Username)
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);
+1
View File
@@ -27,6 +27,7 @@
<body>
<Routes />
<persist-component-state />
<script src="@Assets["_framework/blazor.web.js"]"></script>
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
<script>
+179 -9
View File
@@ -1,15 +1,25 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using timetracker.Shared;
namespace timetracker.Data;
public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNotificationService notifier) : IAuthService
public class AuthService(
IDbContextFactory<TimetrackerDbContext> factory,
UserNotificationService notifier,
ITenantProvider tenantProvider) : IAuthService
{
private const string ReservedSuperAdminUsername = "marc";
public async Task<User?> LoginAsync(string username, string password)
{
await using var db = await factory.CreateDbContextAsync();
// Global query filter automatically restricts lookup to current tenant context
var user = await db.Users
.FirstOrDefaultAsync(u => u.Username == username);
if (user == null) return null;
@@ -19,7 +29,9 @@ public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNo
public async Task<List<User>> GetAllUsersAsync()
{
await using var db = await factory.CreateDbContextAsync();
// Superadmin bypasses query filters to manage all users globally
return await db.Users
.IgnoreQueryFilters()
.OrderBy(u => u.Username)
.ToListAsync();
}
@@ -27,7 +39,7 @@ public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNo
public async Task DeleteUserAsync(int userId)
{
await using var db = await factory.CreateDbContextAsync();
var user = await db.Users.FindAsync(userId);
var user = await db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == userId);
if (user != null)
{
db.Users.Remove(user);
@@ -43,12 +55,19 @@ public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNo
return "Benutzername muss mindestens 3 Zeichen lang sein.";
await using var db = await factory.CreateDbContextAsync();
if (await db.Users.AnyAsync(u => u.Username == newUsername && u.Id != userId))
return "Benutzername bereits vergeben.";
var user = await db.Users.FindAsync(userId);
// Find user ignoring filters so we can rename global admin too if needed
var user = await db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == userId);
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
var isTaken = await db.Users.AnyAsync(u => u.Username == newUsername && u.Id != userId && u.TenantId == user.TenantId);
if (isTaken)
return "Benutzername in dieser Firma bereits vergeben.";
user.Username = newUsername;
await db.SaveChangesAsync();
await notifier.NotifyUsersChangedAsync();
@@ -61,7 +80,7 @@ public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNo
return "Passwort muss mindestens 6 Zeichen lang sein.";
await using var db = await factory.CreateDbContextAsync();
var user = await db.Users.FindAsync(userId);
var user = await db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == userId);
if (user == null) return "Benutzer nicht gefunden.";
if (!VerifyPassword(currentPassword, user.PasswordHash, user.PasswordSalt))
@@ -75,24 +94,167 @@ public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNo
return null;
}
public async Task<(User? User, string? Error)> RegisterAsync(string username, string password, string? honeypot = null) {
public async Task<(User? User, string? Error)> RegisterAsync(string username, string password, string? honeypot = null)
{
if (string.IsNullOrWhiteSpace(username) || username.Length < 3)
return (null, "Benutzername muss mindestens 3 Zeichen lang sein.");
if (string.IsNullOrWhiteSpace(password) || password.Length < 6)
return (null, "Passwort muss mindestens 6 Zeichen lang sein.");
var tenantId = tenantProvider.TenantId;
if (!CanUseReservedUsername(tenantId, username))
{
return (null, "Dieser Benutzername ist reserviert.");
}
await using var db = await factory.CreateDbContextAsync();
// Filter handles checking uniqueness within the same tenant context
if (await db.Users.AnyAsync(u => u.Username == username))
return (null, "Benutzername bereits vergeben.");
return (null, tenantId.HasValue ? "Benutzername in dieser Firma bereits vergeben." : "Benutzername bereits vergeben.");
var (hash, salt) = HashPassword(password);
var user = new User { Username = username, PasswordHash = hash, PasswordSalt = salt };
var user = new User
{
Username = username,
PasswordHash = hash,
PasswordSalt = salt,
TenantId = tenantId
};
db.Users.Add(user);
await db.SaveChangesAsync();
await notifier.NotifyUsersChangedAsync();
return (user, null);
}
public async Task<(Tenant? Tenant, string? Error)> RegisterFirmAsync(RegisterFirmRequest req)
{
if (string.IsNullOrWhiteSpace(req.CompanyName) || req.CompanyName.Length < 3)
return (null, "Firmenname muss mindestens 3 Zeichen lang sein.");
if (string.IsNullOrWhiteSpace(req.Subdomain) || req.Subdomain.Length < 3)
return (null, "Subdomain muss mindestens 3 Zeichen lang sein.");
if (string.IsNullOrWhiteSpace(req.AdminUsername) || req.AdminUsername.Length < 3)
return (null, "Administrator-Benutzername muss mindestens 3 Zeichen lang sein.");
if (string.IsNullOrWhiteSpace(req.AdminPassword) || req.AdminPassword.Length < 6)
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();
if (!subdomain.All(c => char.IsLetterOrDigit(c) || c == '-'))
{
return (null, "Subdomain darf nur Buchstaben, Zahlen und Bindestriche enthalten.");
}
var reservedSubdomains = new[] { "www", "api", "admin", "superadmin", "localhost", "mail", "portal", "app", "dev", "test" };
if (reservedSubdomains.Contains(subdomain))
{
return (null, "Diese Subdomain ist reserviert und kann nicht verwendet werden.");
}
await using var db = await factory.CreateDbContextAsync();
// Tenant checks are global, no filters applied to Tenants table
var exists = await db.Tenants.AnyAsync(t => t.Subdomain == subdomain);
if (exists)
{
return (null, "Diese Subdomain ist bereits vergeben.");
}
await using var transaction = await db.Database.BeginTransactionAsync();
try
{
var tenant = new Tenant
{
Name = req.CompanyName,
Subdomain = subdomain,
IsApproved = false,
CreatedAt = DateTime.UtcNow
};
db.Tenants.Add(tenant);
await db.SaveChangesAsync();
var (hash, salt) = HashPassword(req.AdminPassword);
var user = new User
{
Username = req.AdminUsername,
PasswordHash = hash,
PasswordSalt = salt,
TenantId = tenant.Id,
IsTenantAdmin = true
};
db.Users.Add(user);
await db.SaveChangesAsync();
await transaction.CommitAsync();
await notifier.NotifyUsersChangedAsync();
return (tenant, null);
}
catch (Exception ex)
{
await transaction.RollbackAsync();
return (null, $"Registrierungsfehler: {ex.Message}");
}
}
public async Task<string?> AssignTenantAsync(int userId, int? tenantId)
{
await using var db = await factory.CreateDbContextAsync();
var user = await db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == userId);
if (user == null) return "Benutzer nicht gefunden.";
if (tenantId.HasValue)
{
var tenantExists = await db.Tenants.AnyAsync(t => t.Id == tenantId.Value);
if (!tenantExists) return "Firma existiert nicht.";
}
await using var transaction = await db.Database.BeginTransactionAsync();
try
{
user.TenantId = tenantId;
var settings = await db.AppSettings.IgnoreQueryFilters().Where(s => s.UserId == userId).ToListAsync();
foreach (var s in settings) s.TenantId = tenantId;
var workDays = await db.WorkDays.IgnoreQueryFilters().Where(w => w.UserId == userId).ToListAsync();
foreach (var w in workDays) w.TenantId = tenantId;
var vacationDays = await db.VacationDays.IgnoreQueryFilters().Where(v => v.UserId == userId).ToListAsync();
foreach (var v in vacationDays) v.TenantId = tenantId;
await db.SaveChangesAsync();
await transaction.CommitAsync();
await notifier.NotifyUsersChangedAsync();
return null;
}
catch (Exception ex)
{
await transaction.RollbackAsync();
return $"Fehler beim Verschieben des Benutzers: {ex.Message}";
}
}
public async Task<string?> ResetPasswordAsync(int userId, string newPassword)
{
if (string.IsNullOrWhiteSpace(newPassword) || newPassword.Length < 6)
return "Passwort muss mindestens 6 Zeichen lang sein.";
await using var db = await factory.CreateDbContextAsync();
var user = await db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == userId);
if (user == null) return "Benutzer nicht gefunden.";
var (hash, salt) = HashPassword(newPassword);
user.PasswordHash = hash;
user.PasswordSalt = salt;
await db.SaveChangesAsync();
return null;
}
private static (string hash, string salt) HashPassword(string password)
{
var saltBytes = RandomNumberGenerator.GetBytes(32);
@@ -104,6 +266,14 @@ public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNo
private static bool VerifyPassword(string password, string hash, string salt)
=> 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)
{
var hash = Rfc2898DeriveBytes.Pbkdf2(
+278
View File
@@ -0,0 +1,278 @@
using ClosedXML.Excel;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using timetracker.Shared;
namespace timetracker.Data;
public static class ExcelExporter
{
public static async Task<byte[]> GenerateExcelExportAsync(
int userId,
DateOnly startDate,
DateOnly endDate,
string title,
ITimetrackerService trackerService,
TimetrackerDbContext db)
{
var settings = await trackerService.GetSettingsAsync(userId);
var workDays = await db.WorkDays
.Include(w => w.Breaks)
.Where(w => w.UserId == userId && w.Date >= startDate && w.Date <= endDate)
.ToDictionaryAsync(w => w.Date);
var holidaysList = await db.PublicHolidays
.Where(h => h.Date >= startDate && h.Date <= endDate)
.ToListAsync();
var state = settings.GermanState;
var holidayMap = holidaysList
.Where(h => string.IsNullOrEmpty(h.Counties) || (!string.IsNullOrEmpty(state) && h.Counties.Split(',').Contains(state)))
.ToDictionary(h => h.Date, h => h.Name);
var vacationMap = await db.VacationDays
.Where(v => v.UserId == userId && v.Date >= startDate && v.Date <= endDate)
.ToDictionaryAsync(v => v.Date, v => v.Note ?? "Urlaub");
var user = await db.Users.FindAsync(userId);
var userName = user != null ? user.Username : "Mitarbeiter";
var rows = new List<ExportRow>();
for (var date = startDate; date <= endDate; date = date.AddDays(1))
{
bool isWorkDay = settings.IsWorkDay(date.DayOfWeek);
bool isHoliday = holidayMap.ContainsKey(date);
bool isVacation = vacationMap.ContainsKey(date);
double targetHours = (isWorkDay && !isHoliday && !isVacation) ? settings.DailyTargetHours : 0.0;
string status = "Wochenende";
if (isHoliday) status = "Feiertag";
else if (isVacation) status = "Urlaub";
else if (isWorkDay) status = "Arbeitstag";
TimeSpan? startTime = null;
TimeSpan? endTime = null;
TimeSpan breakTime = TimeSpan.Zero;
double actualHours = 0.0;
string note = "";
if (isHoliday) note = holidayMap[date];
else if (isVacation) note = vacationMap[date];
if (workDays.TryGetValue(date, out var wd))
{
if (wd.StartTime.HasValue) startTime = wd.StartTime.Value.ToTimeSpan();
if (wd.EndTime.HasValue) endTime = wd.EndTime.Value.ToTimeSpan();
if (startTime.HasValue && endTime.HasValue)
{
var gross = endTime.Value - startTime.Value;
if (gross > TimeSpan.Zero)
{
var breakTotal = 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 = breakTotal > minBreak ? breakTotal : minBreak;
breakTime = finalBreak;
actualHours = (gross - finalBreak).TotalHours;
if (actualHours < 0) actualHours = 0.0;
}
}
}
rows.Add(new ExportRow
{
Date = date,
Status = status,
StartTime = startTime,
EndTime = endTime,
BreakTime = breakTime,
ActualHours = actualHours,
TargetHours = targetHours,
DiffHours = actualHours - targetHours,
Note = note
});
}
return ExportToExcel(title, userName, startDate, endDate, rows);
}
public static byte[] ExportToExcel(
string title,
string userName,
DateOnly startDate,
DateOnly endDate,
List<ExportRow> rows)
{
using var workbook = new XLWorkbook();
var worksheet = workbook.Worksheets.Add("Arbeitszeiten");
// Gitterlinien explizit einblenden
worksheet.ShowGridLines = true;
// Title Block
worksheet.Cell("A1").Value = title;
worksheet.Cell("A1").Style.Font.Bold = true;
worksheet.Cell("A1").Style.Font.FontSize = 16;
worksheet.Cell("A1").Style.Font.FontColor = XLColor.FromHtml("#0F172A"); // Slate 900
// Info Block
worksheet.Cell("A2").Value = $"Mitarbeiter: {userName}";
worksheet.Cell("A2").Style.Font.FontSize = 11;
worksheet.Cell("A2").Style.Font.Italic = true;
worksheet.Cell("A3").Value = $"Zeitraum: {startDate:dd.MM.yyyy} bis {endDate:dd.MM.yyyy}";
worksheet.Cell("A3").Style.Font.FontSize = 11;
worksheet.Cell("A3").Style.Font.Italic = true;
// Tabellenüberschriften (Zeile 5)
string[] headers = {
"Datum", "Wochentag", "Status", "Beginn", "Ende",
"Pause", "Netto (Ist)", "Soll-Stunden", "Differenz (Gleitzeit)", "Notiz / Feiertag / Urlaub"
};
for (int i = 0; i < headers.Length; i++)
{
var cell = worksheet.Cell(5, i + 1);
cell.Value = headers[i];
cell.Style.Font.Bold = true;
cell.Style.Font.FontColor = XLColor.White;
cell.Style.Fill.BackgroundColor = XLColor.FromHtml("#0EA5E9"); // Primary color (Sky 500)
cell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
}
// Datenzeilen
int currentRow = 6;
var deCulture = new CultureInfo("de-DE");
foreach (var r in rows)
{
worksheet.Cell(currentRow, 1).Value = r.Date.ToString("dd.MM.yyyy");
worksheet.Cell(currentRow, 2).Value = r.Date.ToString("dddd", deCulture);
worksheet.Cell(currentRow, 3).Value = r.Status;
if (r.StartTime.HasValue)
{
worksheet.Cell(currentRow, 4).Value = r.StartTime.Value.ToString(@"hh\:mm");
}
else
{
worksheet.Cell(currentRow, 4).Value = "—";
}
if (r.EndTime.HasValue)
{
worksheet.Cell(currentRow, 5).Value = r.EndTime.Value.ToString(@"hh\:mm");
}
else
{
worksheet.Cell(currentRow, 5).Value = "—";
}
worksheet.Cell(currentRow, 6).Value = r.BreakTime.TotalMinutes > 0 ? r.BreakTime.ToString(@"hh\:mm") : "—";
worksheet.Cell(currentRow, 7).Value = r.ActualHours;
worksheet.Cell(currentRow, 7).Style.NumberFormat.Format = "0.00";
worksheet.Cell(currentRow, 8).Value = r.TargetHours;
worksheet.Cell(currentRow, 8).Style.NumberFormat.Format = "0.00";
worksheet.Cell(currentRow, 9).Value = r.DiffHours;
worksheet.Cell(currentRow, 9).Style.NumberFormat.Format = "+0.00;-0.00;0.00";
if (r.DiffHours > 0)
{
worksheet.Cell(currentRow, 9).Style.Font.FontColor = XLColor.FromHtml("#16A34A"); // Green 600
worksheet.Cell(currentRow, 9).Style.Font.Bold = true;
}
else if (r.DiffHours < 0)
{
worksheet.Cell(currentRow, 9).Style.Font.FontColor = XLColor.FromHtml("#DC2626"); // Red 600
worksheet.Cell(currentRow, 9).Style.Font.Bold = true;
}
worksheet.Cell(currentRow, 10).Value = r.Note;
// Zebra-Streifen
if (currentRow % 2 == 0)
{
for (int col = 1; col <= headers.Length; col++)
{
worksheet.Cell(currentRow, col).Style.Fill.BackgroundColor = XLColor.FromHtml("#F8FAFC"); // Slate 50
}
}
// Rahmenlinien
for (int col = 1; col <= headers.Length; col++)
{
worksheet.Cell(currentRow, col).Style.Border.BottomBorder = XLBorderStyleValues.Thin;
worksheet.Cell(currentRow, col).Style.Border.BottomBorderColor = XLColor.FromHtml("#E2E8F0");
}
currentRow++;
}
// Summenzeile
currentRow++;
worksheet.Cell(currentRow, 6).Value = "Gesamt:";
worksheet.Cell(currentRow, 6).Style.Font.Bold = true;
worksheet.Cell(currentRow, 6).Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Right;
var actualSumRange = $"G6:G{currentRow - 2}";
worksheet.Cell(currentRow, 7).FormulaA1 = $"SUM({actualSumRange})";
worksheet.Cell(currentRow, 7).Style.Font.Bold = true;
worksheet.Cell(currentRow, 7).Style.NumberFormat.Format = "0.00";
var targetSumRange = $"H6:H{currentRow - 2}";
worksheet.Cell(currentRow, 8).FormulaA1 = $"SUM({targetSumRange})";
worksheet.Cell(currentRow, 8).Style.Font.Bold = true;
worksheet.Cell(currentRow, 8).Style.NumberFormat.Format = "0.00";
var diffSumRange = $"I6:I{currentRow - 2}";
worksheet.Cell(currentRow, 9).FormulaA1 = $"SUM({diffSumRange})";
worksheet.Cell(currentRow, 9).Style.Font.Bold = true;
worksheet.Cell(currentRow, 9).Style.NumberFormat.Format = "+0.00;-0.00;0.00";
// Summenrahmen
for (int col = 6; col <= 9; col++)
{
worksheet.Cell(currentRow, col).Style.Border.TopBorder = XLBorderStyleValues.Thin;
worksheet.Cell(currentRow, col).Style.Border.TopBorderColor = XLColor.Black;
worksheet.Cell(currentRow, col).Style.Border.BottomBorder = XLBorderStyleValues.Double;
worksheet.Cell(currentRow, col).Style.Border.BottomBorderColor = XLColor.Black;
}
// Spaltenbreiten automatisch anpassen
worksheet.Columns().AdjustToContents();
using var memoryStream = new MemoryStream();
workbook.SaveAs(memoryStream);
return memoryStream.ToArray();
}
}
public class ExportRow
{
public DateOnly Date { get; set; }
public string Status { get; set; } = "";
public TimeSpan? StartTime { get; set; }
public TimeSpan? EndTime { get; set; }
public TimeSpan BreakTime { get; set; }
public double ActualHours { get; set; }
public double TargetHours { get; set; }
public double DiffHours { get; set; }
public string Note { get; set; } = "";
}
@@ -0,0 +1,10 @@
using System.Threading.Tasks;
using timetracker.Shared;
namespace timetracker.Data;
public interface ITenantProvider
{
int? TenantId { get; }
Task<Tenant?> GetCurrentTenantAsync();
}
@@ -0,0 +1,254 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using timetracker.Data;
#nullable disable
namespace timetracker.Data.Migrations
{
[DbContext(typeof(TimetrackerDbContext))]
[Migration("20260624204954_AddMultiTenancy")]
partial class AddMultiTenancy
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.8");
modelBuilder.Entity("timetracker.Shared.AppSettings", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<double>("DailyTargetHours")
.HasColumnType("REAL");
b.Property<DateOnly?>("FlexTimeStartDate")
.HasColumnType("TEXT");
b.Property<double>("FlexTimeStartingBalanceHours")
.HasColumnType("REAL");
b.Property<string>("GermanState")
.HasColumnType("TEXT");
b.Property<int>("MinimumBreakMinutes")
.HasColumnType("INTEGER");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.Property<int>("VacationDaysPerYear")
.HasColumnType("INTEGER");
b.Property<bool>("WorkFriday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkMonday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkSaturday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkSunday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkThursday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkTuesday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkWednesday")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("AppSettings");
});
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<TimeOnly?>("EndTime")
.HasColumnType("TEXT");
b.Property<TimeOnly?>("StartTime")
.HasColumnType("TEXT");
b.Property<int>("WorkDayId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("WorkDayId");
b.ToTable("BreakEntries");
});
modelBuilder.Entity("timetracker.Shared.PublicHoliday", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Counties")
.HasColumnType("TEXT");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("PublicHolidays");
});
modelBuilder.Entity("timetracker.Shared.Tenant", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsApproved")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Subdomain")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Subdomain")
.IsUnique();
b.ToTable("Tenants");
});
modelBuilder.Entity("timetracker.Shared.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PasswordSalt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TenantId");
b.ToTable("Users");
});
modelBuilder.Entity("timetracker.Shared.VacationDay", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("VacationDays");
});
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<TimeOnly?>("EndTime")
.HasColumnType("TEXT");
b.Property<TimeOnly?>("StartTime")
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("WorkDays");
});
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
{
b.HasOne("timetracker.Shared.WorkDay", "WorkDay")
.WithMany("Breaks")
.HasForeignKey("WorkDayId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("WorkDay");
});
modelBuilder.Entity("timetracker.Shared.User", b =>
{
b.HasOne("timetracker.Shared.Tenant", "Tenant")
.WithMany()
.HasForeignKey("TenantId");
b.Navigation("Tenant");
});
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
{
b.Navigation("Breaks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,104 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace timetracker.Data.Migrations
{
/// <inheritdoc />
public partial class AddMultiTenancy : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "TenantId",
table: "WorkDays",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "TenantId",
table: "VacationDays",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "TenantId",
table: "Users",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "TenantId",
table: "AppSettings",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateTable(
name: "Tenants",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", nullable: false),
Subdomain = table.Column<string>(type: "TEXT", nullable: false),
IsApproved = table.Column<bool>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Tenants", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Users_TenantId",
table: "Users",
column: "TenantId");
migrationBuilder.CreateIndex(
name: "IX_Tenants_Subdomain",
table: "Tenants",
column: "Subdomain",
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_Users_Tenants_TenantId",
table: "Users",
column: "TenantId",
principalTable: "Tenants",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Users_Tenants_TenantId",
table: "Users");
migrationBuilder.DropTable(
name: "Tenants");
migrationBuilder.DropIndex(
name: "IX_Users_TenantId",
table: "Users");
migrationBuilder.DropColumn(
name: "TenantId",
table: "WorkDays");
migrationBuilder.DropColumn(
name: "TenantId",
table: "VacationDays");
migrationBuilder.DropColumn(
name: "TenantId",
table: "Users");
migrationBuilder.DropColumn(
name: "TenantId",
table: "AppSettings");
}
}
}
@@ -0,0 +1,265 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using timetracker.Data;
#nullable disable
namespace timetracker.Data.Migrations
{
[DbContext(typeof(TimetrackerDbContext))]
[Migration("20260624210436_AddTenantBranding")]
partial class AddTenantBranding
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.8");
modelBuilder.Entity("timetracker.Shared.AppSettings", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<double>("DailyTargetHours")
.HasColumnType("REAL");
b.Property<DateOnly?>("FlexTimeStartDate")
.HasColumnType("TEXT");
b.Property<double>("FlexTimeStartingBalanceHours")
.HasColumnType("REAL");
b.Property<string>("GermanState")
.HasColumnType("TEXT");
b.Property<int>("MinimumBreakMinutes")
.HasColumnType("INTEGER");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.Property<int>("VacationDaysPerYear")
.HasColumnType("INTEGER");
b.Property<bool>("WorkFriday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkMonday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkSaturday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkSunday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkThursday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkTuesday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkWednesday")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("AppSettings");
});
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<TimeOnly?>("EndTime")
.HasColumnType("TEXT");
b.Property<TimeOnly?>("StartTime")
.HasColumnType("TEXT");
b.Property<int>("WorkDayId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("WorkDayId");
b.ToTable("BreakEntries");
});
modelBuilder.Entity("timetracker.Shared.PublicHoliday", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Counties")
.HasColumnType("TEXT");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("PublicHolidays");
});
modelBuilder.Entity("timetracker.Shared.Tenant", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsApproved")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PrimaryColor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SecondaryColor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Subdomain")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Subdomain")
.IsUnique();
b.ToTable("Tenants");
});
modelBuilder.Entity("timetracker.Shared.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("IsTenantAdmin")
.HasColumnType("INTEGER");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PasswordSalt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TenantId");
b.ToTable("Users");
});
modelBuilder.Entity("timetracker.Shared.VacationDay", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("VacationDays");
});
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<TimeOnly?>("EndTime")
.HasColumnType("TEXT");
b.Property<TimeOnly?>("StartTime")
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("WorkDays");
});
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
{
b.HasOne("timetracker.Shared.WorkDay", "WorkDay")
.WithMany("Breaks")
.HasForeignKey("WorkDayId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("WorkDay");
});
modelBuilder.Entity("timetracker.Shared.User", b =>
{
b.HasOne("timetracker.Shared.Tenant", "Tenant")
.WithMany()
.HasForeignKey("TenantId");
b.Navigation("Tenant");
});
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
{
b.Navigation("Breaks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace timetracker.Data.Migrations
{
/// <inheritdoc />
public partial class AddTenantBranding : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsTenantAdmin",
table: "Users",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "PrimaryColor",
table: "Tenants",
type: "TEXT",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "SecondaryColor",
table: "Tenants",
type: "TEXT",
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsTenantAdmin",
table: "Users");
migrationBuilder.DropColumn(
name: "PrimaryColor",
table: "Tenants");
migrationBuilder.DropColumn(
name: "SecondaryColor",
table: "Tenants");
}
}
}
@@ -0,0 +1,265 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using timetracker.Data;
#nullable disable
namespace timetracker.Data.Migrations
{
[DbContext(typeof(TimetrackerDbContext))]
[Migration("20260625184756_PendingChanges")]
partial class PendingChanges
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
modelBuilder.Entity("timetracker.Shared.AppSettings", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<double>("DailyTargetHours")
.HasColumnType("REAL");
b.Property<DateOnly?>("FlexTimeStartDate")
.HasColumnType("TEXT");
b.Property<double>("FlexTimeStartingBalanceHours")
.HasColumnType("REAL");
b.Property<string>("GermanState")
.HasColumnType("TEXT");
b.Property<int>("MinimumBreakMinutes")
.HasColumnType("INTEGER");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.Property<int>("VacationDaysPerYear")
.HasColumnType("INTEGER");
b.Property<bool>("WorkFriday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkMonday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkSaturday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkSunday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkThursday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkTuesday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkWednesday")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("AppSettings");
});
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<TimeOnly?>("EndTime")
.HasColumnType("TEXT");
b.Property<TimeOnly?>("StartTime")
.HasColumnType("TEXT");
b.Property<int>("WorkDayId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("WorkDayId");
b.ToTable("BreakEntries");
});
modelBuilder.Entity("timetracker.Shared.PublicHoliday", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Counties")
.HasColumnType("TEXT");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("PublicHolidays");
});
modelBuilder.Entity("timetracker.Shared.Tenant", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsApproved")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PrimaryColor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SecondaryColor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Subdomain")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Subdomain")
.IsUnique();
b.ToTable("Tenants");
});
modelBuilder.Entity("timetracker.Shared.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("IsTenantAdmin")
.HasColumnType("INTEGER");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PasswordSalt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TenantId");
b.ToTable("Users");
});
modelBuilder.Entity("timetracker.Shared.VacationDay", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("VacationDays");
});
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<TimeOnly?>("EndTime")
.HasColumnType("TEXT");
b.Property<TimeOnly?>("StartTime")
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("WorkDays");
});
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
{
b.HasOne("timetracker.Shared.WorkDay", "WorkDay")
.WithMany("Breaks")
.HasForeignKey("WorkDayId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("WorkDay");
});
modelBuilder.Entity("timetracker.Shared.User", b =>
{
b.HasOne("timetracker.Shared.Tenant", "Tenant")
.WithMany()
.HasForeignKey("TenantId");
b.Navigation("Tenant");
});
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
{
b.Navigation("Breaks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace timetracker.Data.Migrations
{
/// <inheritdoc />
public partial class PendingChanges : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -15,9 +15,9 @@ namespace timetracker.Data.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.8");
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
modelBuilder.Entity("timetracker.Data.AppSettings", b =>
modelBuilder.Entity("timetracker.Shared.AppSettings", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@@ -38,6 +38,9 @@ namespace timetracker.Data.Migrations
b.Property<int>("MinimumBreakMinutes")
.HasColumnType("INTEGER");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
@@ -70,7 +73,7 @@ namespace timetracker.Data.Migrations
b.ToTable("AppSettings");
});
modelBuilder.Entity("timetracker.Data.BreakEntry", b =>
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@@ -92,7 +95,7 @@ namespace timetracker.Data.Migrations
b.ToTable("BreakEntries");
});
modelBuilder.Entity("timetracker.Data.PublicHoliday", b =>
modelBuilder.Entity("timetracker.Shared.PublicHoliday", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@@ -113,12 +116,51 @@ namespace timetracker.Data.Migrations
b.ToTable("PublicHolidays");
});
modelBuilder.Entity("timetracker.Data.User", b =>
modelBuilder.Entity("timetracker.Shared.Tenant", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsApproved")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PrimaryColor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SecondaryColor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Subdomain")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Subdomain")
.IsUnique();
b.ToTable("Tenants");
});
modelBuilder.Entity("timetracker.Shared.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("IsTenantAdmin")
.HasColumnType("INTEGER");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
@@ -127,16 +169,21 @@ namespace timetracker.Data.Migrations
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TenantId");
b.ToTable("Users");
});
modelBuilder.Entity("timetracker.Data.VacationDay", b =>
modelBuilder.Entity("timetracker.Shared.VacationDay", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@@ -148,6 +195,9 @@ namespace timetracker.Data.Migrations
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
@@ -156,7 +206,7 @@ namespace timetracker.Data.Migrations
b.ToTable("VacationDays");
});
modelBuilder.Entity("timetracker.Data.WorkDay", b =>
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@@ -171,6 +221,9 @@ namespace timetracker.Data.Migrations
b.Property<TimeOnly?>("StartTime")
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
@@ -179,9 +232,9 @@ namespace timetracker.Data.Migrations
b.ToTable("WorkDays");
});
modelBuilder.Entity("timetracker.Data.BreakEntry", b =>
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
{
b.HasOne("timetracker.Data.WorkDay", "WorkDay")
b.HasOne("timetracker.Shared.WorkDay", "WorkDay")
.WithMany("Breaks")
.HasForeignKey("WorkDayId")
.OnDelete(DeleteBehavior.Cascade)
@@ -190,7 +243,16 @@ namespace timetracker.Data.Migrations
b.Navigation("WorkDay");
});
modelBuilder.Entity("timetracker.Data.WorkDay", b =>
modelBuilder.Entity("timetracker.Shared.User", b =>
{
b.HasOne("timetracker.Shared.Tenant", "Tenant")
.WithMany()
.HasForeignKey("TenantId");
b.Navigation("Tenant");
});
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
{
b.Navigation("Breaks");
});
+126
View File
@@ -0,0 +1,126 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using timetracker.Shared;
namespace timetracker.Data;
public class TenantProvider : ITenantProvider
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IDbContextFactory<TimetrackerDbContext> _dbContextFactory;
private readonly IConfiguration _configuration;
public TenantProvider(
IHttpContextAccessor httpContextAccessor,
IDbContextFactory<TimetrackerDbContext> dbContextFactory,
IConfiguration configuration)
{
_httpContextAccessor = httpContextAccessor;
_dbContextFactory = dbContextFactory;
_configuration = configuration;
}
public int? TenantId
{
get
{
var httpContext = _httpContextAccessor.HttpContext;
if (httpContext == null) return null;
if (httpContext.Items.TryGetValue("TenantId", out var cachedId))
{
return (int?)cachedId;
}
// Resolve and cache in HttpContext.Items
ResolveTenant();
if (httpContext.Items.TryGetValue("TenantId", out cachedId))
{
return (int?)cachedId;
}
return null;
}
}
public Task<Tenant?> GetCurrentTenantAsync()
{
var httpContext = _httpContextAccessor.HttpContext;
if (httpContext == null) return Task.FromResult<Tenant?>(null);
if (httpContext.Items.TryGetValue("CurrentTenant", out var cachedTenant))
{
return Task.FromResult((Tenant?)cachedTenant);
}
ResolveTenant();
if (httpContext.Items.TryGetValue("CurrentTenant", out cachedTenant))
{
return Task.FromResult((Tenant?)cachedTenant);
}
return Task.FromResult<Tenant?>(null);
}
private void ResolveTenant()
{
var httpContext = _httpContextAccessor.HttpContext;
if (httpContext == null) return;
// Prevent double resolution in the same request
if (httpContext.Items.ContainsKey("TenantResolved")) return;
httpContext.Items["TenantResolved"] = true;
var host = httpContext.Request.Host.Host;
var subdomain = GetSubdomain(host);
if (!string.IsNullOrEmpty(subdomain))
{
using var db = _dbContextFactory.CreateDbContext();
var tenant = db.Tenants
.FirstOrDefault(t => t.Subdomain.ToLower() == subdomain.ToLower());
if (tenant != null)
{
httpContext.Items["CurrentTenant"] = tenant;
httpContext.Items["TenantId"] = tenant.Id;
}
}
}
private string? GetSubdomain(string host)
{
var baseDomain = _configuration["TenantSettings:BaseDomain"] ?? "localhost";
// Ignore port if included in host
var hostName = host.Split(':')[0];
if (hostName.Equals(baseDomain, StringComparison.OrdinalIgnoreCase))
{
return null; // Main domain
}
if (hostName.EndsWith("." + baseDomain, StringComparison.OrdinalIgnoreCase))
{
return hostName.Substring(0, hostName.Length - baseDomain.Length - 1);
}
// Fallback for localhost testing
if (baseDomain.Equals("localhost", StringComparison.OrdinalIgnoreCase))
{
var parts = hostName.Split('.');
if (parts.Length > 1 && !hostName.Equals("localhost", StringComparison.OrdinalIgnoreCase))
{
return parts[0];
}
}
return null;
}
}
@@ -1,14 +1,72 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using timetracker.Shared;
namespace timetracker.Data;
public class TimetrackerDbContext(DbContextOptions<TimetrackerDbContext> options) : DbContext(options)
public class TimetrackerDbContext : DbContext
{
private readonly IServiceProvider _serviceProvider;
private ITenantProvider? _tenantProvider;
private ITenantProvider TenantProvider => _tenantProvider ??= _serviceProvider.GetRequiredService<ITenantProvider>();
public TimetrackerDbContext(
DbContextOptions<TimetrackerDbContext> options,
IServiceProvider serviceProvider) : base(options)
{
_serviceProvider = serviceProvider;
}
public DbSet<Tenant> Tenants => Set<Tenant>();
public DbSet<User> Users => Set<User>();
public DbSet<WorkDay> WorkDays => Set<WorkDay>();
public DbSet<BreakEntry> BreakEntries => Set<BreakEntry>();
public DbSet<AppSettings> AppSettings => Set<AppSettings>();
public DbSet<VacationDay> VacationDays => Set<VacationDay>();
public DbSet<PublicHoliday> PublicHolidays => Set<PublicHoliday>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Subdomain index unique
modelBuilder.Entity<Tenant>()
.HasIndex(t => t.Subdomain)
.IsUnique();
// Global Query Filters (scopes database views by resolved TenantId)
modelBuilder.Entity<User>().HasQueryFilter(u => u.TenantId == TenantProvider.TenantId);
modelBuilder.Entity<WorkDay>().HasQueryFilter(w => w.TenantId == TenantProvider.TenantId);
modelBuilder.Entity<VacationDay>().HasQueryFilter(v => v.TenantId == TenantProvider.TenantId);
modelBuilder.Entity<AppSettings>().HasQueryFilter(s => s.TenantId == TenantProvider.TenantId);
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
var tenantId = TenantProvider.TenantId;
if (tenantId.HasValue)
{
foreach (var entry in ChangeTracker.Entries())
{
if (entry.State == EntityState.Added)
{
var tenantIdProp = entry.Entity.GetType().GetProperty("TenantId");
if (tenantIdProp != null && tenantIdProp.CanWrite)
{
var currentValue = tenantIdProp.GetValue(entry.Entity);
if (currentValue == null || (currentValue is int val && val == 0))
{
tenantIdProp.SetValue(entry.Entity, tenantId.Value);
}
}
}
}
}
return base.SaveChangesAsync(cancellationToken);
}
}
@@ -199,4 +199,144 @@ public class TimetrackerService(IDbContextFactory<TimetrackerDbContext> factory)
.OrderBy(w => w.Date)
.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
RUN mkdir -p /data
RUN mkdir -p /app/logs
ENV ASPNETCORE_HTTP_PORTS=8080
ENV ASPNETCORE_ENVIRONMENT=Production
ENV ASPNETCORE_FORWARDEDHEADERS_ENABLED=true
ENV TIMETRACKER_DB_PATH=/data/timetracker.db
ENV TIMETRACKER_LOG_DIR=/app/logs
ENV EnableHttpsRedirect=false
EXPOSE 8080
VOLUME ["/data"]
VOLUME ["/app/logs"]
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();
}
}
+664 -13
View File
@@ -1,16 +1,41 @@
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Components.Authorization;
using MudBlazor.Services;
using Serilog;
using timetracker.Server;
using timetracker.Server.Components;
using timetracker.Data;
using timetracker.Shared;
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
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
@@ -45,8 +70,11 @@ builder.Services.AddAuthorization(options =>
});
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddScoped<AuthenticationStateProvider, PersistingServerAuthenticationStateProvider>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddTransient<ITenantProvider, TenantProvider>();
// Add SignalR
builder.Services.AddSignalR();
builder.Services.AddSingleton<UserNotificationService>();
@@ -55,6 +83,7 @@ builder.Services.AddSingleton<IUserNotificationService>(sp => sp.GetRequiredServ
// Register DB-backed services as the interfaces
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<ITimetrackerService, TimetrackerService>();
builder.Services.AddScoped<timetracker.Server.Services.PdfGeneratorService>();
builder.Services.AddScoped<IHolidayService, HolidayService>();
// Add services to the container.
@@ -63,6 +92,31 @@ builder.Services.AddRazorComponents()
builder.Services.AddMudServices();
builder.Services.AddHttpClient<HolidayService>();
builder.Services.AddScoped(sp =>
{
try
{
var navigationManager = sp.GetService<Microsoft.AspNetCore.Components.NavigationManager>();
if (navigationManager != null && !string.IsNullOrEmpty(navigationManager.BaseUri))
{
return new System.Net.Http.HttpClient { BaseAddress = new Uri(navigationManager.BaseUri) };
}
}
catch (InvalidOperationException)
{
// NavigationManager is registered but not initialized in this request scope
}
var httpContextAccessor = sp.GetService<Microsoft.AspNetCore.Http.IHttpContextAccessor>();
var request = httpContextAccessor?.HttpContext?.Request;
if (request != null)
{
var baseUri = $"{request.Scheme}://{request.Host}{request.PathBase}/";
return new System.Net.Http.HttpClient { BaseAddress = new Uri(baseUri) };
}
return new System.Net.Http.HttpClient();
});
builder.Services.AddRateLimiter(options =>
{
@@ -87,18 +141,22 @@ if (dbProvider.Equals("PostgreSQL", StringComparison.OrdinalIgnoreCase))
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
?? builder.Configuration["ConnectionStrings:DefaultConnection"];
builder.Services.AddDbContextFactory<TimetrackerDbContext>(options =>
options.UseNpgsql(connectionString));
options.UseNpgsql(connectionString)
.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
}
else
{
var dbPath = Environment.GetEnvironmentVariable("TIMETRACKER_DB_PATH")
?? Path.Combine(builder.Environment.ContentRootPath, "timetracker.db");
builder.Services.AddDbContextFactory<TimetrackerDbContext>(options =>
options.UseSqlite($"Data Source={dbPath}"));
options.UseSqlite($"Data Source={dbPath}")
.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
}
var app = builder.Build();
app.Logger.LogInformation("File logging active. Writing logs to {LogDirectory}", logDirectory);
// Migrate or Ensure Database Created
using (var scope = app.Services.CreateScope())
{
@@ -106,11 +164,205 @@ using (var scope = app.Services.CreateScope())
await using var db = await factory.CreateDbContextAsync();
if (dbProvider.Equals("PostgreSQL", StringComparison.OrdinalIgnoreCase))
{
await db.Database.EnsureCreatedAsync();
try
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
{
await conn.OpenAsync();
}
// Check if AppSettings table already exists
using (var cmdCheck = conn.CreateCommand())
{
cmdCheck.CommandText = "SELECT EXISTS (SELECT FROM pg_tables WHERE schemaname = 'public' AND tablename = 'AppSettings');";
var appSettingsExists = (bool)(await cmdCheck.ExecuteScalarAsync() ?? false);
if (appSettingsExists)
{
// Ensure __EFMigrationsHistory table exists
using (var cmdCreateHistory = conn.CreateCommand())
{
cmdCreateHistory.CommandText = @"
CREATE TABLE IF NOT EXISTS ""__EFMigrationsHistory"" (
""MigrationId"" character varying(150) NOT NULL,
""ProductVersion"" character varying(32) NOT NULL,
CONSTRAINT ""PK___EFMigrationsHistory"" PRIMARY KEY (""MigrationId"")
);";
await cmdCreateHistory.ExecuteNonQueryAsync();
}
// Seed the history table conditionally for each migration based on actual schema presence
using (var cmdSyncHistory = conn.CreateCommand())
{
cmdSyncHistory.CommandText = @"
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');
INSERT INTO ""__EFMigrationsHistory"" (""MigrationId"", ""ProductVersion"")
SELECT '20260522081459_AddMultiUser', '10.0.9'
WHERE NOT EXISTS (SELECT 1 FROM ""__EFMigrationsHistory"" WHERE ""MigrationId"" = '20260522081459_AddMultiUser');
INSERT INTO ""__EFMigrationsHistory"" (""MigrationId"", ""ProductVersion"")
SELECT '20260607213215_AddFlexTimeAndHolidayState', '10.0.9'
WHERE NOT EXISTS (SELECT 1 FROM ""__EFMigrationsHistory"" WHERE ""MigrationId"" = '20260607213215_AddFlexTimeAndHolidayState');
-- Seed AddMultiTenancy only if Tenants table exists
INSERT INTO ""__EFMigrationsHistory"" (""MigrationId"", ""ProductVersion"")
SELECT '20260624204954_AddMultiTenancy', '10.0.9'
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)
{
Console.WriteLine($"Fehler beim Vorbereiten der PostgreSQL Migrationshistorie: {ex.Message}");
}
}
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();
}
}
@@ -162,54 +414,93 @@ app.MapGet("/api/auth/me", (HttpContext ctx) =>
{
var idClaim = ctx.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var name = ctx.User.Identity.Name ?? "";
var isTenantAdmin = ctx.User.FindFirst("IsTenantAdmin")?.Value == "true";
if (int.TryParse(idClaim, out var id))
{
return Results.Ok(new UserInfo { Id = id, Username = name });
return Results.Ok(new UserInfo { Id = id, Username = name, IsTenantAdmin = isTenantAdmin });
}
}
return Results.Unauthorized();
});
app.MapPost("/api/auth/login", async (HttpContext ctx, [FromBody] LoginRequest req, IAuthService authService) =>
app.MapPost("/api/auth/login", async (HttpContext ctx, [FromBody] LoginRequest req, IAuthService authService, ITenantProvider tenantProvider) =>
{
var tenant = await tenantProvider.GetCurrentTenantAsync();
if (tenant != null && !tenant.IsApproved)
{
return Results.BadRequest("Diese Firma ist noch nicht freigeschaltet.");
}
var user = await authService.LoginAsync(req.Username, req.Password);
if (user == null)
return Results.BadRequest("Benutzername oder Passwort falsch.");
var claims = new[] {
if (tenant != null)
{
if (user.TenantId != tenant.Id)
{
return Results.BadRequest("Benutzer gehört nicht zu dieser Firma.");
}
}
else
{
if (user.Username != "marc" && user.TenantId != null)
{
return Results.BadRequest("Benutzer gehört zu einer Firma. Bitte melden Sie sich über Ihre Firmen-Subdomain an.");
}
}
var claims = new List<Claim> {
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username)
new Claim(ClaimTypes.Name, user.Username),
new Claim("IsTenantAdmin", user.IsTenantAdmin ? "true" : "false")
};
if (user.TenantId.HasValue)
{
claims.Add(new Claim("TenantId", user.TenantId.Value.ToString()));
}
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(identity),
new AuthenticationProperties { IsPersistent = true });
return Results.Ok(new UserInfo { Id = user.Id, Username = user.Username });
return Results.Ok(new UserInfo { Id = user.Id, Username = user.Username, IsTenantAdmin = user.IsTenantAdmin });
}).RequireRateLimiting("auth-limit");
app.MapPost("/api/auth/register", async (HttpContext ctx, [FromBody] RegisterRequest req, IAuthService authService) =>
app.MapPost("/api/auth/register", async (HttpContext ctx, [FromBody] RegisterRequest req, IAuthService authService, ITenantProvider tenantProvider) =>
{
if (!string.IsNullOrEmpty(req.Honeypot))
{
// Silently reject bots
return Results.BadRequest("Registrierung fehlgeschlagen.");
}
var tenant = await tenantProvider.GetCurrentTenantAsync();
if (tenant != null && !tenant.IsApproved)
{
return Results.BadRequest("Registrierung blockiert: Firma noch nicht freigeschaltet.");
}
var (user, error) = await authService.RegisterAsync(req.Username, req.Password);
if (user == null)
return Results.BadRequest(error ?? "Registrierung fehlgeschlagen.");
var claims = new[] {
var claims = new List<Claim> {
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username)
new Claim(ClaimTypes.Name, user.Username),
new Claim("IsTenantAdmin", user.IsTenantAdmin ? "true" : "false")
};
if (user.TenantId.HasValue)
{
claims.Add(new Claim("TenantId", user.TenantId.Value.ToString()));
}
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(identity),
new AuthenticationProperties { IsPersistent = true });
return Results.Ok(new UserInfo { Id = user.Id, Username = user.Username });
return Results.Ok(new UserInfo { Id = user.Id, Username = user.Username, IsTenantAdmin = user.IsTenantAdmin });
}).RequireRateLimiting("auth-limit");
app.MapGet("/auth/logout", async (HttpContext ctx) =>
@@ -253,9 +544,57 @@ usersApi.MapPut("/{userId:int}/change-password", async (int userId, [FromBody] C
return Results.Ok();
});
usersApi.MapPut("/{userId:int}/assign-tenant", async (int userId, [FromBody] AssignTenantRequest req, IAuthService authService) =>
{
var error = await authService.AssignTenantAsync(userId, req.TenantId);
if (error != null)
{
return Results.BadRequest(error);
}
return Results.Ok();
});
usersApi.MapPut("/{userId:int}/reset-password", async (int userId, [FromBody] ResetPasswordRequest req, IAuthService authService) =>
{
var error = await authService.ResetPasswordAsync(userId, req.NewPassword);
if (error != null)
{
return Results.BadRequest(error);
}
return Results.Ok();
});
// ── Timetracker-API-Endpoints (Protected) ─────────────────────────────────────
var trackerApi = app.MapGroup("/api/tracker").RequireAuthorization();
trackerApi.MapPost("/tenant/settings", async (ClaimsPrincipal claimsPrincipal, [FromBody] TenantSettingsDto dto, ITenantProvider tenantProvider, TimetrackerDbContext db) =>
{
var isAdmin = claimsPrincipal.FindFirst("IsTenantAdmin")?.Value == "true";
if (!isAdmin) return Results.Forbid();
var tenant = await tenantProvider.GetCurrentTenantAsync();
if (tenant == null) return Results.BadRequest("Keine Firma aktiv.");
var dbTenant = await db.Tenants.FindAsync(tenant.Id);
if (dbTenant == null) return Results.NotFound("Firma nicht gefunden.");
if (!string.IsNullOrWhiteSpace(dto.Name))
{
dbTenant.Name = dto.Name.Trim();
}
if (!string.IsNullOrWhiteSpace(dto.PrimaryColor))
{
dbTenant.PrimaryColor = dto.PrimaryColor.Trim();
}
if (!string.IsNullOrWhiteSpace(dto.SecondaryColor))
{
dbTenant.SecondaryColor = dto.SecondaryColor.Trim();
}
await db.SaveChangesAsync();
return Results.Ok();
});
trackerApi.MapGet("/week", async (ClaimsPrincipal claimsPrincipal, [FromQuery] string monday, ITimetrackerService trackerService) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
@@ -345,6 +684,146 @@ trackerApi.MapGet("/month", async (ClaimsPrincipal claimsPrincipal, [FromQuery]
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) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
if (DateOnly.TryParse(monday, out var date))
{
var mondayDate = date;
int diff = ((int)mondayDate.DayOfWeek - (int)DayOfWeek.Monday + 7) % 7;
mondayDate = mondayDate.AddDays(-diff);
var sundayDate = mondayDate.AddDays(6);
var fileBytes = await ExcelExporter.GenerateExcelExportAsync(
userId,
mondayDate,
sundayDate,
$"Wochenbericht KW {System.Globalization.ISOWeek.GetWeekOfYear(new DateTime(mondayDate.Year, mondayDate.Month, mondayDate.Day))}",
trackerService,
db);
return Results.File(
fileBytes,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
$"Wochenbericht_KW_{System.Globalization.ISOWeek.GetWeekOfYear(new DateTime(mondayDate.Year, mondayDate.Month, mondayDate.Day))}_{mondayDate:yyyyMMdd}.xlsx");
}
return Results.BadRequest("Ungültiges Datum.");
});
trackerApi.MapGet("/export/month", async (ClaimsPrincipal claimsPrincipal, [FromQuery] int year, [FromQuery] int month, ITimetrackerService trackerService, TimetrackerDbContext db) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
if (month < 1 || month > 12) return Results.BadRequest("Ungültiger Monat.");
var startDate = new DateOnly(year, month, 1);
var endDate = startDate.AddMonths(1).AddDays(-1);
var deCulture = new System.Globalization.CultureInfo("de-DE");
var monthName = deCulture.DateTimeFormat.GetMonthName(month);
var fileBytes = await ExcelExporter.GenerateExcelExportAsync(
userId,
startDate,
endDate,
$"Monatsbericht {monthName} {year}",
trackerService,
db);
return Results.File(
fileBytes,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
$"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) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
var startDate = new DateOnly(year, 1, 1);
var endDate = new DateOnly(year, 12, 31);
var fileBytes = await ExcelExporter.GenerateExcelExportAsync(
userId,
startDate,
endDate,
$"Jahresbericht {year}",
trackerService,
db);
return Results.File(
fileBytes,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
$"Jahresbericht_{year}.xlsx");
});
// ── Holiday-API-Endpoints (Protected) ─────────────────────────────────────────
var holidaysApi = app.MapGroup("/api/holidays").RequireAuthorization();
@@ -366,8 +845,180 @@ holidaysApi.MapDelete("/{id:int}", async (int id, IHolidayService holidayService
return Results.NoContent();
});
app.MapGet("/api/tenant/current", async (ITenantProvider tenantProvider) =>
{
var tenant = await tenantProvider.GetCurrentTenantAsync();
if (tenant == null)
{
return Results.Ok(new TenantInfo { IsTenant = false });
}
return Results.Ok(new TenantInfo
{
IsTenant = true,
Name = tenant.Name,
Subdomain = tenant.Subdomain,
IsApproved = tenant.IsApproved,
PrimaryColor = tenant.PrimaryColor,
SecondaryColor = tenant.SecondaryColor
});
});
app.MapPost("/api/auth/register-firm", async ([FromBody] RegisterFirmRequest req, IAuthService authService) =>
{
var (tenant, error) = await authService.RegisterFirmAsync(req);
if (tenant == null)
return Results.BadRequest(error ?? "Registrierung der Firma fehlgeschlagen.");
return Results.Ok(new { Success = true });
}).RequireRateLimiting("auth-limit");
// ── SuperAdmin-API-Endpoints (Protected) ───────────────────────────────────────────
var superAdminApi = app.MapGroup("/api/superadmin").RequireAuthorization("AdminOnly");
superAdminApi.MapGet("/tenants", async (TimetrackerDbContext db) =>
{
var tenants = await db.Tenants
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
var stats = new List<object>();
foreach (var t in tenants)
{
var userCount = await db.Users.IgnoreQueryFilters().CountAsync(u => u.TenantId == t.Id);
var workDaysCount = await db.WorkDays.IgnoreQueryFilters().CountAsync(w => w.TenantId == t.Id);
var tenantUsers = await db.Users.IgnoreQueryFilters()
.Where(u => u.TenantId == t.Id)
.Select(u => new { u.Id, u.Username, u.IsTenantAdmin })
.ToListAsync();
stats.Add(new
{
t.Id,
t.Name,
t.Subdomain,
t.IsApproved,
t.CreatedAt,
UserCount = userCount,
WorkDaysCount = workDaysCount,
Users = tenantUsers
});
}
return Results.Ok(stats);
});
superAdminApi.MapPost("/tenants/{id:int}/approve", async (int id, TimetrackerDbContext db) =>
{
var tenant = await db.Tenants.FindAsync(id);
if (tenant == null) return Results.NotFound();
tenant.IsApproved = true;
await db.SaveChangesAsync();
return Results.Ok();
});
superAdminApi.MapPost("/tenants/{id:int}/toggle-active", async (int id, TimetrackerDbContext db) =>
{
var tenant = await db.Tenants.FindAsync(id);
if (tenant == null) return Results.NotFound();
tenant.IsApproved = !tenant.IsApproved;
await db.SaveChangesAsync();
return Results.Ok();
});
superAdminApi.MapDelete("/tenants/{id:int}", async (int id, TimetrackerDbContext db, UserNotificationService notifier) =>
{
var tenant = await db.Tenants.FindAsync(id);
if (tenant == null) return Results.NotFound();
await using var transaction = await db.Database.BeginTransactionAsync();
try
{
var userIds = await db.Users.IgnoreQueryFilters()
.Where(u => u.TenantId == id)
.Select(u => u.Id)
.ToListAsync();
var workDays = await db.WorkDays.IgnoreQueryFilters().Where(w => w.TenantId == id).ToListAsync();
db.WorkDays.RemoveRange(workDays);
var vacationDays = await db.VacationDays.IgnoreQueryFilters().Where(v => v.TenantId == id).ToListAsync();
db.VacationDays.RemoveRange(vacationDays);
var settings = await db.AppSettings.IgnoreQueryFilters().Where(s => s.TenantId == id).ToListAsync();
db.AppSettings.RemoveRange(settings);
var users = await db.Users.IgnoreQueryFilters().Where(u => u.TenantId == id).ToListAsync();
db.Users.RemoveRange(users);
db.Tenants.Remove(tenant);
await db.SaveChangesAsync();
await transaction.CommitAsync();
foreach (var uid in userIds)
{
await notifier.NotifyUserDeletedAsync(uid);
}
await notifier.NotifyUsersChangedAsync();
return Results.NoContent();
}
catch (Exception ex)
{
await transaction.RollbackAsync();
return Results.BadRequest($"Fehler beim Löschen der Firma: {ex.Message}");
}
});
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 ──────────────────────────────────────────────────
public record LoginRequest(string Username, string Password);
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": {
"LogLevel": {
"Default": "Information",
+10
View File
@@ -1,4 +1,14 @@
{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Information"
}
}
},
"Logging": {
"LogLevel": {
"Default": "Information",
File diff suppressed because it is too large Load Diff
@@ -8,11 +8,16 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ClosedXML" Version="0.105.0" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
<PackageReference Include="MudBlazor" Version="9.4.0" />
<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" />
</ItemGroup>
<ItemGroup>
@@ -20,4 +25,8 @@
<ProjectReference Include="..\timetracker.Client\timetracker.Client.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Fonts\*.ttf" />
</ItemGroup>
</Project>
+1
View File
@@ -4,6 +4,7 @@ public class AppSettings
{
public int Id { get; set; }
public int UserId { get; set; }
public int? TenantId { get; set; }
public double DailyTargetHours { get; set; } = 7.5;
public int MinimumBreakMinutes { get; set; } = 30;
public int VacationDaysPerYear { get; set; } = 30;
@@ -0,0 +1,3 @@
namespace timetracker.Shared;
public record AssignTenantRequest(int? TenantId);
+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; }
}
}
+3
View File
@@ -8,4 +8,7 @@ public interface IAuthService
Task<string?> RenameUserAsync(int userId, string newUsername);
Task<string?> ChangePasswordAsync(int userId, string currentPassword, string newPassword);
Task<(User? User, string? Error)> RegisterAsync(string username, string password, string? honeypot = null);
Task<(Tenant? Tenant, string? Error)> RegisterFirmAsync(RegisterFirmRequest req);
Task<string?> AssignTenantAsync(int userId, int? tenantId);
Task<string?> ResetPasswordAsync(int userId, string newPassword);
}
@@ -11,4 +11,6 @@ public interface ITimetrackerService
Task RemoveVacationDayAsync(int userId, int id);
Task<TimeSpan> GetTotalOvertimeAsync(int userId, AppSettings settings);
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);
}
@@ -0,0 +1,8 @@
namespace timetracker.Shared;
public record RegisterFirmRequest(
string CompanyName,
string Subdomain,
string AdminUsername,
string AdminPassword
);
@@ -0,0 +1,3 @@
namespace timetracker.Shared;
public record ResetPasswordRequest(string NewPassword);
+14
View File
@@ -0,0 +1,14 @@
using System;
namespace timetracker.Shared;
public class Tenant
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string Subdomain { get; set; } = "";
public bool IsApproved { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public string PrimaryColor { get; set; } = "#0EA5E9";
public string SecondaryColor { get; set; } = "#0EA5E9";
}
+11
View File
@@ -0,0 +1,11 @@
namespace timetracker.Shared;
public class TenantInfo
{
public bool IsTenant { get; set; }
public string? Name { get; set; }
public string? Subdomain { get; set; }
public bool IsApproved { get; set; }
public string? PrimaryColor { get; set; }
public string? SecondaryColor { get; set; }
}
+8
View File
@@ -0,0 +1,8 @@
namespace timetracker.Shared;
public class TenantSettingsDto
{
public string Name { get; set; } = "";
public string PrimaryColor { get; set; } = "#0EA5E9";
public string SecondaryColor { get; set; } = "#0EA5E9";
}
+23
View File
@@ -0,0 +1,23 @@
using System;
namespace timetracker.Shared;
public class TenantStats
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string Subdomain { get; set; } = "";
public bool IsApproved { get; set; }
public DateTime CreatedAt { get; set; }
public int UserCount { get; set; }
public int WorkDaysCount { get; set; }
public List<TenantUserDto> Users { get; set; } = new();
public bool ShowDetails { get; set; }
}
public class TenantUserDto
{
public int Id { get; set; }
public string Username { get; set; } = "";
public bool IsTenantAdmin { get; set; }
}
+4
View File
@@ -6,4 +6,8 @@ public class User
public string Username { get; set; } = "";
public string PasswordHash { get; set; } = "";
public string PasswordSalt { get; set; } = "";
public int? TenantId { get; set; }
public Tenant? Tenant { get; set; }
public bool IsTenantAdmin { get; set; }
}
+2
View File
@@ -4,4 +4,6 @@ public class UserInfo
{
public int Id { get; set; }
public string Username { get; set; } = "";
public bool IsTenantAdmin { get; set; }
public bool IsAuthenticated { get; set; }
}
+1
View File
@@ -4,6 +4,7 @@ public class VacationDay
{
public int Id { get; set; }
public int UserId { get; set; }
public int? TenantId { get; set; }
public DateOnly Date { get; set; }
public string? Note { get; set; }
}
+1
View File
@@ -4,6 +4,7 @@ public class WorkDay
{
public int Id { get; set; }
public int UserId { get; set; }
public int? TenantId { get; set; }
public DateOnly Date { get; set; }
public TimeOnly? StartTime { get; set; }
public TimeOnly? EndTime { get; set; }
+4
View File
@@ -0,0 +1,4 @@
# TODOs für morgen
- [ ] **Single-User-Variante bauen:** Eine Möglichkeit implementieren, sich einfach und unabhängig von einem bestimmten Tenant zu registrieren.
- [ ] **Pricing-Modell konzipieren:** Gedanken über ein geeignetes Preismodell machen (z. B. SaaS-Preise, Mandanten-Preismodell).