Compare commits
19 Commits
807d56d071
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f47b4d86ba | |||
| e2e57143b8 | |||
| 62e58fc9ad | |||
| 4a69095f30 | |||
| 42a97a36b4 | |||
| 0c3eb3a323 | |||
| 6ff8743dad | |||
| ab966c66d0 | |||
| 56fa73656f | |||
| 782be5dd36 | |||
| f291d9db7d | |||
| 52842072e5 | |||
| 7d9eac498a | |||
| f4512a807c | |||
| a89779a00c | |||
| 711ea2a00b | |||
| febc875834 | |||
| c27b1088ee | |||
| 2305e4b728 |
@@ -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
@@ -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
|
||||
@@ -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%
|
||||
@@ -0,0 +1,3 @@
|
||||
using System;
|
||||
using PdfSharp.Fonts;
|
||||
class Program { static void Main() { Console.WriteLine("Hello"); } }
|
||||
@@ -29,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>
|
||||
@@ -48,6 +48,9 @@
|
||||
</MudLayout>
|
||||
|
||||
@code {
|
||||
[PersistentState(AllowUpdates = true)]
|
||||
public TenantInfo? PersistentTenantInfo { get; set; }
|
||||
|
||||
private bool _drawerOpen = true;
|
||||
private bool _isDarkMode;
|
||||
|
||||
@@ -55,44 +58,60 @@
|
||||
{
|
||||
UserNotificationService.OnUserDeleted += HandleUserDeleted;
|
||||
|
||||
try
|
||||
if (PersistentTenantInfo != null)
|
||||
{
|
||||
var tenantInfo = await Http.GetFromJsonAsync<TenantInfo>("api/tenant/current");
|
||||
if (tenantInfo?.IsTenant == true && !string.IsNullOrEmpty(tenantInfo.PrimaryColor))
|
||||
ApplyTheme(PersistentTenantInfo);
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
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;
|
||||
var tenantInfo = await Http.GetFromJsonAsync<TenantInfo>("api/tenant/current");
|
||||
if (tenantInfo != null)
|
||||
{
|
||||
ApplyTheme(tenantInfo);
|
||||
PersistentTenantInfo = tenantInfo;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore background error
|
||||
}
|
||||
}
|
||||
catch
|
||||
}
|
||||
|
||||
private void ApplyTheme(TenantInfo tenantInfo)
|
||||
{
|
||||
if (tenantInfo.IsTenant && !string.IsNullOrEmpty(tenantInfo.PrimaryColor))
|
||||
{
|
||||
// Ignore background error
|
||||
var primaryColor = tenantInfo.PrimaryColor;
|
||||
var secondaryColor = tenantInfo.SecondaryColor ?? tenantInfo.PrimaryColor;
|
||||
|
||||
_theme.PaletteLight.Primary = primaryColor;
|
||||
_theme.PaletteLight.Secondary = secondaryColor;
|
||||
_theme.PaletteDark.Primary = primaryColor;
|
||||
_theme.PaletteDark.Secondary = secondaryColor;
|
||||
|
||||
// Dynamically apply background colors to navbar
|
||||
_theme.PaletteLight.AppbarBackground = primaryColor;
|
||||
_theme.PaletteLight.DrawerBackground = primaryColor;
|
||||
_theme.PaletteDark.AppbarBackground = primaryColor;
|
||||
_theme.PaletteDark.DrawerBackground = primaryColor;
|
||||
|
||||
// Adjust text/icon contrast based on background luminance
|
||||
var isLight = IsLightColor(primaryColor);
|
||||
var textCol = isLight ? "#0F172A" : "#FFFFFF";
|
||||
var subTextCol = isLight ? "rgba(15, 23, 42, 0.70)" : "rgba(255, 255, 255, 0.70)";
|
||||
|
||||
_theme.PaletteLight.AppbarText = textCol;
|
||||
_theme.PaletteLight.DrawerText = subTextCol;
|
||||
_theme.PaletteLight.DrawerIcon = subTextCol;
|
||||
_theme.PaletteLight.PrimaryContrastText = textCol;
|
||||
|
||||
_theme.PaletteDark.AppbarText = textCol;
|
||||
_theme.PaletteDark.DrawerText = subTextCol;
|
||||
_theme.PaletteDark.DrawerIcon = subTextCol;
|
||||
_theme.PaletteDark.PrimaryContrastText = textCol;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject HttpClient Http
|
||||
<div class="nav-container onboarding-nav-menu">
|
||||
<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>
|
||||
@@ -13,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>
|
||||
@@ -96,24 +99,36 @@
|
||||
</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()
|
||||
{
|
||||
try
|
||||
if (PersistentCompanyName != null)
|
||||
{
|
||||
var tenantInfo = await Http.GetFromJsonAsync<TenantInfo>("api/tenant/current");
|
||||
if (tenantInfo?.IsTenant == true && !string.IsNullOrEmpty(tenantInfo.Name))
|
||||
{
|
||||
_companyName = tenantInfo.Name;
|
||||
}
|
||||
_companyName = PersistentCompanyName;
|
||||
}
|
||||
catch
|
||||
else
|
||||
{
|
||||
// Ignore background error
|
||||
try
|
||||
{
|
||||
var tenantInfo = await Http.GetFromJsonAsync<TenantInfo>("api/tenant/current");
|
||||
if (tenantInfo?.IsTenant == true && !string.IsNullOrEmpty(tenantInfo.Name))
|
||||
{
|
||||
_companyName = tenantInfo.Name;
|
||||
PersistentCompanyName = _companyName;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore background error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -118,41 +118,61 @@
|
||||
color: var(--mud-palette-appbar-text) !important;
|
||||
}
|
||||
|
||||
/* 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, color-mix(in srgb, var(--mud-palette-secondary) 15%, transparent) 0%, color-mix(in srgb, var(--mud-palette-secondary) 4%, transparent) 100%) !important;
|
||||
background: transparent !important;
|
||||
font-weight: 600 !important;
|
||||
box-shadow: inset 3px 0 0 var(--mud-palette-secondary) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
::deep .custom-nav-link.active,
|
||||
::deep .custom-nav-link .active,
|
||||
::deep .custom-nav-link.active .mud-nav-link-text,
|
||||
::deep .custom-nav-link .active .mud-nav-link-text,
|
||||
::deep .custom-nav-link.active .mud-nav-link-icon-default,
|
||||
::deep .custom-nav-link .active .mud-nav-link-icon-default {
|
||||
::deep .custom-nav-link .active .mud-nav-link-text {
|
||||
color: var(--mud-palette-secondary) !important;
|
||||
}
|
||||
|
||||
/* Admin link color overrides & active state */
|
||||
::deep .custom-nav-link.active .mud-nav-link-icon-default,
|
||||
::deep .custom-nav-link .active .mud-nav-link-icon-default {
|
||||
color: var(--mud-palette-secondary) !important;
|
||||
background-color: color-mix(in srgb, var(--mud-palette-secondary) 15%, transparent) !important;
|
||||
border-radius: 50% !important;
|
||||
padding: 6px !important;
|
||||
margin: -6px 8px -6px -6px !important;
|
||||
width: 36px !important;
|
||||
height: 36px !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
/* Admin link active state (Circle around icon) */
|
||||
::deep .admin-link .mud-nav-link-icon-default {
|
||||
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 */
|
||||
@@ -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();
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
@inject IUserNotificationService UserNotificationService
|
||||
@inject HttpClient Http
|
||||
@inject IDialogService DialogService
|
||||
@inject NavigationManager NavigationManager
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>Benutzerverwaltung – Timetracker</PageTitle>
|
||||
@@ -131,6 +132,12 @@ else
|
||||
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"
|
||||
@@ -152,6 +159,12 @@ 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;
|
||||
@@ -161,23 +174,35 @@ else
|
||||
|
||||
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;
|
||||
|
||||
try
|
||||
if (PersistentUsers == null)
|
||||
{
|
||||
_users = await AuthService.GetAllUsersAsync();
|
||||
_tenants = await Http.GetFromJsonAsync<List<TenantStats>>("api/superadmin/tenants") ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Fehler beim Laden der Daten: {ex.Message}", Severity.Error);
|
||||
}
|
||||
try
|
||||
{
|
||||
_users = await AuthService.GetAllUsersAsync();
|
||||
_tenants = await Http.GetFromJsonAsync<List<TenantStats>>("api/superadmin/tenants") ?? [];
|
||||
|
||||
_loading = false;
|
||||
PersistentUsers = _users;
|
||||
PersistentTenants = _tenants;
|
||||
_loading = false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Fehler beim Laden der Daten: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshUsers()
|
||||
@@ -279,4 +304,19 @@ else
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DownloadUserPdf(User user)
|
||||
{
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall, FullWidth = true };
|
||||
var dialog = await DialogService.ShowAsync<MonthSelectorDialog>($"Stundenzettel für {user.Username}", options);
|
||||
var result = await dialog.Result;
|
||||
|
||||
if (result != null && !result.Canceled && result.Data is MonthSelectorDialog.MonthSelectorResult data)
|
||||
{
|
||||
int year = data.Year;
|
||||
int month = data.Month;
|
||||
|
||||
NavigationManager.NavigateTo($"api/tracker/export/month/pdf?userId={user.Id}&year={year}&month={month}", forceLoad: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,15 +60,19 @@
|
||||
|
||||
public static readonly List<Release> Releases =
|
||||
[
|
||||
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"),
|
||||
new("Neu", "Excel-Export"),
|
||||
new("Neu", "Multi-Tenant Integration"),
|
||||
new("Upgrade", "Verbesserte Single-User Ansicht")
|
||||
], Highlighted: true),
|
||||
]),
|
||||
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,15 +205,38 @@ 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);
|
||||
_settings = await TrackerService.GetSettingsAsync(_userId);
|
||||
|
||||
if (PersistentSettings == null || PersistentHolidays == null)
|
||||
{
|
||||
_settings = await TrackerService.GetSettingsAsync(_userId);
|
||||
await LoadHolidays();
|
||||
|
||||
PersistentSettings = _settings;
|
||||
PersistentHolidays = _holidays;
|
||||
PersistentSubLabel = _subLabel;
|
||||
PersistentYear = _year;
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await LoadHolidays();
|
||||
_loading = false;
|
||||
}
|
||||
await LoadHolidays();
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task LoadHolidays()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@page "/"
|
||||
@page "/week"
|
||||
@rendermode InteractiveWebAssembly
|
||||
@attribute [Authorize]
|
||||
@inject ITimetrackerService TrackerService
|
||||
@@ -388,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;
|
||||
@@ -409,18 +433,45 @@ 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));
|
||||
_settings = await TrackerService.GetSettingsAsync(_userId);
|
||||
|
||||
var loadWeekTask = LoadWeek();
|
||||
var overtimeTask = TrackerService.GetTotalOvertimeAsync(_userId, _settings);
|
||||
if (PersistentSettings == null || PersistentDays == null || PersistentTotalOvertime == null)
|
||||
{
|
||||
_settings = await TrackerService.GetSettingsAsync(_userId);
|
||||
|
||||
await Task.WhenAll(loadWeekTask, overtimeTask);
|
||||
_totalOvertime = await overtimeTask;
|
||||
var loadWeekTask = LoadWeek();
|
||||
var overtimeTask = TrackerService.GetTotalOvertimeAsync(_userId, _settings);
|
||||
|
||||
await Task.WhenAll(loadWeekTask, overtimeTask);
|
||||
_totalOvertime = await overtimeTask;
|
||||
|
||||
PersistentSettings = _settings;
|
||||
PersistentDays = _days;
|
||||
PersistentTotalOvertime = _totalOvertime;
|
||||
PersistentHolidays = _holidays;
|
||||
PersistentHolidayYear = _holidayYear;
|
||||
PersistentVacations = _vacations;
|
||||
PersistentWeekLabel = _weekLabel;
|
||||
PersistentWeekSubLabel = _weekSubLabel;
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -431,8 +482,6 @@ else
|
||||
{
|
||||
// Ignored during prerendering/SSR
|
||||
}
|
||||
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task HandleOnboardingFinished()
|
||||
@@ -693,7 +742,7 @@ else
|
||||
}
|
||||
|
||||
// ── ViewModels ────────────────────────────────────────────────
|
||||
private sealed class DayVm
|
||||
public sealed class DayVm
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int UserId { get; set; }
|
||||
@@ -743,7 +792,7 @@ else
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class BreakVm
|
||||
public sealed class BreakVm
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public TimeSpan? Start { get; set; }
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<PageTitle>@GetPageTitle()</PageTitle>
|
||||
|
||||
@if (_tenantInfo == null)
|
||||
@if (TenantInfo == null)
|
||||
{
|
||||
@* Loading indicator while resolving Tenant *@
|
||||
<MudContainer MaxWidth="MaxWidth.Small" Class="mt-16 text-center">
|
||||
@@ -17,7 +17,7 @@
|
||||
<MudText Class="mt-4" Color="Color.Secondary">Lade Timetracker…</MudText>
|
||||
</MudContainer>
|
||||
}
|
||||
else if (_tenantInfo.IsTenant && !_tenantInfo.IsApproved)
|
||||
else if (TenantInfo.IsTenant && !TenantInfo.IsApproved)
|
||||
{
|
||||
@* Pending Approval Screen *@
|
||||
<MudContainer MaxWidth="MaxWidth.Small" Class="mt-16">
|
||||
@@ -34,7 +34,7 @@ else if (_tenantInfo.IsTenant && !_tenantInfo.IsApproved)
|
||||
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.
|
||||
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" />
|
||||
@@ -64,9 +64,9 @@ else
|
||||
<MudIcon Icon="@Icons.Material.Filled.AccessTime"
|
||||
Style="font-size:4rem; color:#0EA5E9" />
|
||||
<MudText Typo="Typo.h4" Style="font-weight:700; color:#0EA5E9">
|
||||
@(_tenantInfo.IsTenant ? _tenantInfo.Name : "Timetracker SaaS")
|
||||
@(TenantInfo.IsTenant ? TenantInfo.Name : "Timetracker SaaS")
|
||||
</MudText>
|
||||
@if (!_tenantInfo.IsTenant)
|
||||
@if (!TenantInfo.IsTenant)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Zentrales Portal</MudText>
|
||||
}
|
||||
@@ -168,7 +168,7 @@ else
|
||||
}
|
||||
else if (_activeTab == 1)
|
||||
{
|
||||
@if (_tenantInfo.IsTenant)
|
||||
@if (TenantInfo.IsTenant)
|
||||
{
|
||||
@* ── Employee Register Form ── *@
|
||||
<MudStack Spacing="3">
|
||||
@@ -359,7 +359,7 @@ else
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@if (!_tenantInfo.IsTenant && _activeTab != 2)
|
||||
@if (!TenantInfo.IsTenant && _activeTab != 2)
|
||||
{
|
||||
<MudDivider Class="my-4" />
|
||||
<MudStack AlignItems="AlignItems.Center">
|
||||
@@ -378,7 +378,9 @@ else
|
||||
}
|
||||
|
||||
@code {
|
||||
private TenantInfo? _tenantInfo;
|
||||
[PersistentState(AllowUpdates = true)]
|
||||
public TenantInfo? TenantInfo { get; set; }
|
||||
|
||||
private int _activeTab = 0;
|
||||
private string? _error;
|
||||
private bool _loading;
|
||||
@@ -399,15 +401,18 @@ else
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
if (TenantInfo is null)
|
||||
{
|
||||
_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 };
|
||||
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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,8 +435,8 @@ else
|
||||
|
||||
private string GetPageTitle()
|
||||
{
|
||||
if (_tenantInfo == null) return "Lade…";
|
||||
return _tenantInfo.IsTenant ? $"Anmelden bei {_tenantInfo.Name} – Timetracker" : "Anmelden – Timetracker SaaS";
|
||||
if (TenantInfo == null) return "Lade…";
|
||||
return TenantInfo.IsTenant ? $"Anmelden bei {TenantInfo.Name} – Timetracker" : "Anmelden – Timetracker SaaS";
|
||||
}
|
||||
|
||||
private string GetDomainSuffix()
|
||||
|
||||
@@ -33,6 +33,10 @@ else
|
||||
</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" />
|
||||
@@ -155,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;
|
||||
@@ -174,13 +205,41 @@ 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);
|
||||
_settings = await TrackerService.GetSettingsAsync(_userId);
|
||||
await LoadMonth();
|
||||
_loading = false;
|
||||
|
||||
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()
|
||||
@@ -278,6 +337,11 @@ else
|
||||
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;
|
||||
@@ -317,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; }
|
||||
|
||||
@@ -163,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 = "";
|
||||
@@ -184,18 +199,38 @@ 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);
|
||||
_username = authState.User.Identity?.Name ?? "";
|
||||
_newUsername = _username;
|
||||
_isAdmin = _username.Equals("marc", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
_settings = await TrackerService.GetSettingsAsync(_userId);
|
||||
_overtime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings);
|
||||
_loading = false;
|
||||
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)
|
||||
|
||||
@@ -511,6 +511,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 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;
|
||||
@@ -571,36 +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);
|
||||
_settings = await TrackerService.GetSettingsAsync(_userId);
|
||||
|
||||
_isTenantAdmin = authState.User.IsInRole("TenantAdmin") || authState.User.HasClaim("IsTenantAdmin", "true");
|
||||
if (_isTenantAdmin)
|
||||
if (PersistentSettings != null)
|
||||
{
|
||||
try
|
||||
_isTenantAdmin = authState.User.IsInRole("TenantAdmin") || authState.User.HasClaim("IsTenantAdmin", "true");
|
||||
}
|
||||
else
|
||||
{
|
||||
_isTenantAdmin = authState.User.IsInRole("TenantAdmin") || authState.User.HasClaim("IsTenantAdmin", "true");
|
||||
_settings = await TrackerService.GetSettingsAsync(_userId);
|
||||
|
||||
if (_isTenantAdmin)
|
||||
{
|
||||
_tenantInfo = await Http.GetFromJsonAsync<TenantInfo>("api/tenant/current");
|
||||
if (_tenantInfo != null)
|
||||
try
|
||||
{
|
||||
_tenantName = _tenantInfo.Name ?? "";
|
||||
_tenantPrimaryColor = _tenantInfo.PrimaryColor ?? "#0EA5E9";
|
||||
_tenantSecondaryColor = _tenantInfo.SecondaryColor ?? "#0EA5E9";
|
||||
_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);
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
var loadVacationsTask = LoadVacations();
|
||||
var loadHolidaysTask = HolidayService.GetHolidaysAsync(_holYear, _settings.GermanState);
|
||||
|
||||
await Task.WhenAll(loadVacationsTask, loadHolidaysTask);
|
||||
_holHolidays = await loadHolidaysTask;
|
||||
}
|
||||
|
||||
private async Task LoadVacations()
|
||||
|
||||
@@ -324,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();
|
||||
@@ -349,84 +370,106 @@ else
|
||||
|
||||
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);
|
||||
|
||||
_settings = await TrackerService.GetSettingsAsync(_userId);
|
||||
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var monday = GetMonday(today);
|
||||
|
||||
var dbDays = await TrackerService.GetWeekAsync(_userId, monday);
|
||||
_rawDbDaysCount = dbDays.Count;
|
||||
_totalOvertime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings);
|
||||
|
||||
_weekTargetHours = 0;
|
||||
_weekWorkedHours = 0;
|
||||
_dbMatchStatus.Clear();
|
||||
|
||||
_days = Enumerable.Range(0, 7).Select(i =>
|
||||
if (PersistentSettings == null || PersistentDays == null)
|
||||
{
|
||||
var date = monday.AddDays(i);
|
||||
bool isWorkDay = _settings.IsWorkDay(date.DayOfWeek);
|
||||
double target = isWorkDay ? _settings.DailyTargetHours : 0.0;
|
||||
_settings = await TrackerService.GetSettingsAsync(_userId);
|
||||
|
||||
if (isWorkDay)
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var monday = GetMonday(today);
|
||||
|
||||
var dbDays = await TrackerService.GetWeekAsync(_userId, monday);
|
||||
_rawDbDaysCount = dbDays.Count;
|
||||
_totalOvertime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings);
|
||||
|
||||
_weekTargetHours = 0;
|
||||
_weekWorkedHours = 0;
|
||||
_dbMatchStatus.Clear();
|
||||
|
||||
_days = Enumerable.Range(0, 7).Select(i =>
|
||||
{
|
||||
_weekTargetHours += _settings.DailyTargetHours;
|
||||
}
|
||||
var date = monday.AddDays(i);
|
||||
bool isWorkDay = _settings.IsWorkDay(date.DayOfWeek);
|
||||
double target = isWorkDay ? _settings.DailyTargetHours : 0.0;
|
||||
|
||||
var wd = dbDays.FirstOrDefault(d => d.Date == date);
|
||||
double worked = 0.0;
|
||||
|
||||
if (wd != null)
|
||||
{
|
||||
_dbMatchStatus[date] = $"Ja (Id={wd.Id}, Start={wd.StartTime}, Ende={wd.EndTime})";
|
||||
|
||||
if (wd.StartTime != null && wd.EndTime != null)
|
||||
if (isWorkDay)
|
||||
{
|
||||
var gross = wd.EndTime.Value.ToTimeSpan() - wd.StartTime.Value.ToTimeSpan();
|
||||
if (gross > TimeSpan.Zero)
|
||||
_weekTargetHours += _settings.DailyTargetHours;
|
||||
}
|
||||
|
||||
var wd = dbDays.FirstOrDefault(d => d.Date == date);
|
||||
double worked = 0.0;
|
||||
|
||||
if (wd != null)
|
||||
{
|
||||
_dbMatchStatus[date] = $"Ja (Id={wd.Id}, Start={wd.StartTime}, Ende={wd.EndTime})";
|
||||
|
||||
if (wd.StartTime != null && wd.EndTime != null)
|
||||
{
|
||||
var breakTotal = wd.Breaks
|
||||
.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;
|
||||
worked = (gross - finalBreak).TotalHours;
|
||||
if (worked < 0) worked = 0.0;
|
||||
var gross = wd.EndTime.Value.ToTimeSpan() - wd.StartTime.Value.ToTimeSpan();
|
||||
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;
|
||||
worked = (gross - finalBreak).TotalHours;
|
||||
if (worked < 0) worked = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_dbMatchStatus[date] = "Nein";
|
||||
}
|
||||
else
|
||||
{
|
||||
_dbMatchStatus[date] = "Nein";
|
||||
}
|
||||
|
||||
_weekWorkedHours += worked;
|
||||
_weekWorkedHours += worked;
|
||||
|
||||
return new DayStat
|
||||
{
|
||||
Date = date,
|
||||
DayName = date.ToString("dddd", _deCulture),
|
||||
DayShortName = date.ToString("ddd", _deCulture),
|
||||
WorkedHours = worked,
|
||||
TargetHours = target,
|
||||
IsToday = date == today,
|
||||
IsWorkDay = isWorkDay
|
||||
};
|
||||
}).ToList();
|
||||
return new DayStat
|
||||
{
|
||||
Date = date,
|
||||
DayName = date.ToString("dddd", _deCulture),
|
||||
DayShortName = date.ToString("ddd", _deCulture),
|
||||
WorkedHours = worked,
|
||||
TargetHours = target,
|
||||
IsToday = date == today,
|
||||
IsWorkDay = isWorkDay
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
_weekOvertimeHours = _weekWorkedHours - _weekTargetHours;
|
||||
_weekOvertimeHours = _weekWorkedHours - _weekTargetHours;
|
||||
|
||||
var maxWorked = _days.Max(d => d.WorkedHours);
|
||||
_maxHoursValue = Math.Max(10.0, Math.Max(maxWorked, _settings.DailyTargetHours));
|
||||
var maxWorked = _days.Max(d => d.WorkedHours);
|
||||
_maxHoursValue = Math.Max(10.0, Math.Max(maxWorked, _settings.DailyTargetHours));
|
||||
|
||||
_loading = false;
|
||||
PersistentSettings = _settings;
|
||||
PersistentDays = _days;
|
||||
PersistentTotalOvertime = _totalOvertime;
|
||||
PersistentWeekWorkedHours = _weekWorkedHours;
|
||||
PersistentWeekTargetHours = _weekTargetHours;
|
||||
PersistentWeekOvertimeHours = _weekOvertimeHours;
|
||||
PersistentMaxHoursValue = _maxHoursValue;
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static DateOnly GetMonday(DateOnly date)
|
||||
@@ -468,7 +511,7 @@ else
|
||||
NavigationManager.NavigateTo($"api/tracker/export/year?year={_exportYear}", forceLoad: true);
|
||||
}
|
||||
|
||||
private sealed class DayStat
|
||||
public sealed class DayStat
|
||||
{
|
||||
public DateOnly Date { get; set; }
|
||||
public string DayName { get; set; } = "";
|
||||
|
||||
@@ -167,12 +167,23 @@ else
|
||||
}
|
||||
|
||||
@code {
|
||||
[PersistentState(AllowUpdates = true)]
|
||||
public List<TenantStats>? PersistentTenants { get; set; }
|
||||
|
||||
private bool _loading = true;
|
||||
private List<TenantStats> _tenants = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadTenants();
|
||||
if (PersistentTenants != null)
|
||||
{
|
||||
_tenants = PersistentTenants;
|
||||
_loading = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
await LoadTenants();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadTenants()
|
||||
@@ -181,6 +192,7 @@ else
|
||||
try
|
||||
{
|
||||
_tenants = await Http.GetFromJsonAsync<List<TenantStats>>("api/superadmin/tenants") ?? [];
|
||||
PersistentTenants = _tenants;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -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,13 +259,37 @@ 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);
|
||||
_settings = await TrackerService.GetSettingsAsync(_userId);
|
||||
await LoadYear();
|
||||
_loading = false;
|
||||
|
||||
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,
|
||||
|
||||
@@ -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,20 +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("IsTenantAdmin", userInfo.IsTenantAdmin ? "true" : "false"),
|
||||
new Claim(ClaimTypes.Role, userInfo.IsTenantAdmin ? "TenantAdmin" : "Employee")
|
||||
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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -14,6 +14,8 @@ public class AuthService(
|
||||
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();
|
||||
@@ -58,6 +60,9 @@ public class AuthService(
|
||||
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)
|
||||
@@ -97,7 +102,7 @@ public class AuthService(
|
||||
return (null, "Passwort muss mindestens 6 Zeichen lang sein.");
|
||||
|
||||
var tenantId = tenantProvider.TenantId;
|
||||
if (tenantId == null && username.Equals("marc", StringComparison.OrdinalIgnoreCase))
|
||||
if (!CanUseReservedUsername(tenantId, username))
|
||||
{
|
||||
return (null, "Dieser Benutzername ist reserviert.");
|
||||
}
|
||||
@@ -132,6 +137,9 @@ public class AuthService(
|
||||
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 == '-'))
|
||||
{
|
||||
@@ -258,6 +266,14 @@ public class AuthService(
|
||||
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(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
@@ -13,7 +13,6 @@ namespace timetracker.Data.Migrations
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsTenantAdmin",
|
||||
table: "Users",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+299
-19
@@ -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,6 +70,7 @@ builder.Services.AddAuthorization(options =>
|
||||
});
|
||||
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
builder.Services.AddScoped<AuthenticationStateProvider, PersistingServerAuthenticationStateProvider>();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
|
||||
builder.Services.AddTransient<ITenantProvider, TenantProvider>();
|
||||
@@ -57,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.
|
||||
@@ -128,12 +155,13 @@ else
|
||||
|
||||
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())
|
||||
{
|
||||
var factory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<TimetrackerDbContext>>();
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
|
||||
if (dbProvider.Equals("PostgreSQL", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
@@ -164,28 +192,52 @@ using (var scope = app.Services.CreateScope())
|
||||
await cmdCreateHistory.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
// Check if the initial migration is already recorded
|
||||
using (var cmdCheckHistory = conn.CreateCommand())
|
||||
// Seed the history table conditionally for each migration based on actual schema presence
|
||||
using (var cmdSyncHistory = conn.CreateCommand())
|
||||
{
|
||||
cmdCheckHistory.CommandText = @"SELECT COUNT(*) FROM ""__EFMigrationsHistory"" WHERE ""MigrationId"" = '20260520133634_Initial';";
|
||||
var historyCount = Convert.ToInt32(await cmdCheckHistory.ExecuteScalarAsync() ?? 0);
|
||||
cmdSyncHistory.CommandText = @"
|
||||
INSERT INTO ""__EFMigrationsHistory"" (""MigrationId"", ""ProductVersion"")
|
||||
SELECT '20260520133634_Initial', '10.0.9'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM ""__EFMigrationsHistory"" WHERE ""MigrationId"" = '20260520133634_Initial');
|
||||
|
||||
if (historyCount == 0)
|
||||
{
|
||||
// Seed the history table with the migrations that were already created by EnsureCreated in older versions
|
||||
using (var cmdInsertHistory = conn.CreateCommand())
|
||||
{
|
||||
cmdInsertHistory.CommandText = @"
|
||||
INSERT INTO ""__EFMigrationsHistory"" (""MigrationId"", ""ProductVersion"") VALUES
|
||||
('20260520133634_Initial', '10.0.9'),
|
||||
('20260520200000_AddPublicHolidays', '10.0.9'),
|
||||
('20260522081459_AddMultiUser', '10.0.9'),
|
||||
('20260607213215_AddFlexTimeAndHolidayState', '10.0.9');";
|
||||
await cmdInsertHistory.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
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)
|
||||
@@ -195,6 +247,123 @@ using (var scope = app.Services.CreateScope())
|
||||
}
|
||||
|
||||
await db.Database.MigrateAsync();
|
||||
|
||||
if (dbProvider.Equals("PostgreSQL", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
var conn = db.Database.GetDbConnection();
|
||||
if (conn.State != System.Data.ConnectionState.Open)
|
||||
{
|
||||
await conn.OpenAsync();
|
||||
}
|
||||
|
||||
foreach (var tableName in new[] { "Users", "Tenants", "AppSettings", "WorkDays", "BreakEntries", "VacationDays", "PublicHolidays" })
|
||||
{
|
||||
await EnsurePostgresIntegerPrimaryKeyDefaultAsync(conn, tableName);
|
||||
}
|
||||
|
||||
// Check if column IsTenantAdmin in Users table is of type integer
|
||||
using (var cmdCheckType = conn.CreateCommand())
|
||||
{
|
||||
cmdCheckType.CommandText = @"
|
||||
SELECT data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'Users' AND column_name = 'IsTenantAdmin';";
|
||||
var dataType = await cmdCheckType.ExecuteScalarAsync() as string;
|
||||
if (dataType != null && dataType.Equals("integer", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Drop legacy integer default before changing the column type.
|
||||
using (var cmdAlterType = conn.CreateCommand())
|
||||
{
|
||||
cmdAlterType.CommandText = @"
|
||||
ALTER TABLE ""Users""
|
||||
ALTER COLUMN ""IsTenantAdmin"" DROP DEFAULT,
|
||||
ALTER COLUMN ""IsTenantAdmin"" TYPE boolean
|
||||
USING (""IsTenantAdmin"" <> 0),
|
||||
ALTER COLUMN ""IsTenantAdmin"" SET DEFAULT FALSE;";
|
||||
await cmdAlterType.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if column IsApproved in Tenants table is of type integer
|
||||
using (var cmdCheckApproved = conn.CreateCommand())
|
||||
{
|
||||
cmdCheckApproved.CommandText = @"
|
||||
SELECT data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'Tenants' AND column_name = 'IsApproved';";
|
||||
var dataType = await cmdCheckApproved.ExecuteScalarAsync() as string;
|
||||
if (dataType != null && dataType.Equals("integer", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Drop legacy integer default before changing the column type.
|
||||
using (var cmdAlterApproved = conn.CreateCommand())
|
||||
{
|
||||
cmdAlterApproved.CommandText = @"
|
||||
ALTER TABLE ""Tenants""
|
||||
ALTER COLUMN ""IsApproved"" DROP DEFAULT,
|
||||
ALTER COLUMN ""IsApproved"" TYPE boolean
|
||||
USING (""IsApproved"" <> 0),
|
||||
ALTER COLUMN ""IsApproved"" SET DEFAULT FALSE;";
|
||||
await cmdAlterApproved.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if column CreatedAt in Tenants table is of type text or character varying
|
||||
using (var cmdCheckCreatedAt = conn.CreateCommand())
|
||||
{
|
||||
cmdCheckCreatedAt.CommandText = @"
|
||||
SELECT data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'Tenants' AND column_name = 'CreatedAt';";
|
||||
var dataType = await cmdCheckCreatedAt.ExecuteScalarAsync() as string;
|
||||
if (dataType != null && (dataType.Equals("text", StringComparison.OrdinalIgnoreCase) || dataType.Equals("character varying", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
// Alter the column type to timestamp without time zone
|
||||
using (var cmdAlterCreatedAt = conn.CreateCommand())
|
||||
{
|
||||
cmdAlterCreatedAt.CommandText = @"
|
||||
ALTER TABLE ""Tenants""
|
||||
ALTER COLUMN ""CreatedAt"" TYPE timestamp without time zone
|
||||
USING (""CreatedAt""::timestamp without time zone);";
|
||||
await cmdAlterCreatedAt.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Fehler beim Konvertieren der PostgreSQL-Spaltentypen (IsTenantAdmin/IsApproved/CreatedAt): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
var superAdmin = await db.Users
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(u => u.TenantId == null && u.Username == SuperAdminUsername);
|
||||
|
||||
if (superAdmin == null)
|
||||
{
|
||||
var (passwordHash, passwordSalt) = HashPassword(SuperAdminPassword);
|
||||
db.Users.Add(new User
|
||||
{
|
||||
Username = SuperAdminUsername,
|
||||
PasswordHash = passwordHash,
|
||||
PasswordSalt = passwordSalt,
|
||||
TenantId = null,
|
||||
IsTenantAdmin = true
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
else if (IsLegacySeedPassword(superAdmin.PasswordHash, superAdmin.PasswordSalt, SuperAdminPassword))
|
||||
{
|
||||
var (passwordHash, passwordSalt) = HashPassword(SuperAdminPassword);
|
||||
superAdmin.PasswordHash = passwordHash;
|
||||
superAdmin.PasswordSalt = passwordSalt;
|
||||
superAdmin.IsTenantAdmin = true;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
var forwardedHeadersOptions = new ForwardedHeadersOptions
|
||||
@@ -515,6 +684,24 @@ 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;
|
||||
@@ -570,6 +757,51 @@ trackerApi.MapGet("/export/month", async (ClaimsPrincipal claimsPrincipal, [From
|
||||
$"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;
|
||||
@@ -739,6 +971,54 @@ superAdminApi.MapDelete("/tenants/{id:int}", async (int id, TimetrackerDbContext
|
||||
|
||||
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",
|
||||
|
||||
@@ -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
@@ -14,6 +14,9 @@
|
||||
<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>
|
||||
|
||||
@@ -22,4 +25,8 @@
|
||||
<ProjectReference Include="..\timetracker.Client\timetracker.Client.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Fonts\*.ttf" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace timetracker.Shared
|
||||
{
|
||||
public class DashboardStatsVm
|
||||
{
|
||||
public TimeSpan? EarliestStart { get; set; }
|
||||
public TimeSpan? LatestEnd { get; set; }
|
||||
public TimeSpan AverageBreak { get; set; }
|
||||
public int CurrentStreak { get; set; }
|
||||
public int LongestStreak { get; set; }
|
||||
}
|
||||
|
||||
public class DashboardChartDataVm
|
||||
{
|
||||
public List<MonthlyOvertimeVm> MonthlyOvertime { get; set; } = new();
|
||||
public int WorkDaysCount { get; set; }
|
||||
public int VacationDaysCount { get; set; }
|
||||
public int SickDaysCount { get; set; }
|
||||
public int PublicHolidaysCount { get; set; }
|
||||
}
|
||||
|
||||
public class MonthlyOvertimeVm
|
||||
{
|
||||
public string MonthName { get; set; } = "";
|
||||
public double OvertimeHours { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -11,4 +11,6 @@ public interface ITimetrackerService
|
||||
Task RemoveVacationDayAsync(int userId, int id);
|
||||
Task<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);
|
||||
}
|
||||
|
||||
@@ -5,4 +5,5 @@ public class UserInfo
|
||||
public int Id { get; set; }
|
||||
public string Username { get; set; } = "";
|
||||
public bool IsTenantAdmin { get; set; }
|
||||
public bool IsAuthenticated { get; set; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user