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