Compare commits
2 Commits
670259a983
...
953257bcef
| Author | SHA1 | Date | |
|---|---|---|---|
| 953257bcef | |||
| 92dd6f7432 |
@@ -6,10 +6,33 @@ public sealed record AdminActivityDto(string Label, string Age);
|
||||
|
||||
public sealed record AdminTopCategoryDto(string Category, int Votes);
|
||||
|
||||
public sealed record AdminRiskFlagDto(
|
||||
int Id,
|
||||
string Source,
|
||||
string Type,
|
||||
string Severity,
|
||||
string Status,
|
||||
string Summary,
|
||||
string? TwitchUserId,
|
||||
string CreatedFromIp,
|
||||
DateTimeOffset CreatedAt,
|
||||
string MetadataJson);
|
||||
|
||||
public sealed record AdminAuditEntryDto(
|
||||
int Id,
|
||||
string AdminTwitchUserId,
|
||||
string ActionType,
|
||||
string EntityType,
|
||||
string EntityId,
|
||||
string Summary,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public sealed record AdminDashboardResponse(
|
||||
IEnumerable<AdminMetricDto> Metrics,
|
||||
IEnumerable<AdminActivityDto> Activities,
|
||||
IEnumerable<AdminTopCategoryDto> TopCategories);
|
||||
IEnumerable<AdminTopCategoryDto> TopCategories,
|
||||
IEnumerable<AdminRiskFlagDto> RiskFlags,
|
||||
IEnumerable<AdminAuditEntryDto> AuditEntries);
|
||||
|
||||
public sealed record AdminSeasonListItemDto(
|
||||
int Id,
|
||||
@@ -76,3 +99,5 @@ public sealed record ApproveNominationRequest(
|
||||
string? DisplayName,
|
||||
string? ChannelSlug,
|
||||
string? Platform);
|
||||
|
||||
public sealed record ResolveRiskFlagRequest(string Status);
|
||||
|
||||
@@ -13,6 +13,8 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
||||
public DbSet<VoteBallot> VoteBallots => Set<VoteBallot>();
|
||||
public DbSet<VoteEntry> VoteEntries => Set<VoteEntry>();
|
||||
public DbSet<UserSession> UserSessions => Set<UserSession>();
|
||||
public DbSet<RiskFlag> RiskFlags => Set<RiskFlag>();
|
||||
public DbSet<AdminAuditEntry> AdminAuditEntries => Set<AdminAuditEntry>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -62,6 +64,30 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
|
||||
entity.Property(item => item.TwitchUserId).HasMaxLength(120);
|
||||
entity.Property(item => item.DisplayName).HasMaxLength(120);
|
||||
entity.Property(item => item.Role).HasMaxLength(40);
|
||||
entity.Property(item => item.CreatedFromIp).HasMaxLength(80);
|
||||
entity.Property(item => item.UserAgent).HasMaxLength(400);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RiskFlag>(entity =>
|
||||
{
|
||||
entity.Property(item => item.TwitchUserId).HasMaxLength(120);
|
||||
entity.Property(item => item.Source).HasMaxLength(80);
|
||||
entity.Property(item => item.Type).HasMaxLength(80);
|
||||
entity.Property(item => item.Severity).HasMaxLength(20);
|
||||
entity.Property(item => item.Status).HasMaxLength(20);
|
||||
entity.Property(item => item.Summary).HasMaxLength(240);
|
||||
entity.Property(item => item.CreatedFromIp).HasMaxLength(80);
|
||||
entity.Property(item => item.UserAgent).HasMaxLength(400);
|
||||
entity.Property(item => item.ReviewedByTwitchId).HasMaxLength(120);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<AdminAuditEntry>(entity =>
|
||||
{
|
||||
entity.Property(item => item.AdminTwitchUserId).HasMaxLength(120);
|
||||
entity.Property(item => item.ActionType).HasMaxLength(80);
|
||||
entity.Property(item => item.EntityType).HasMaxLength(80);
|
||||
entity.Property(item => item.EntityId).HasMaxLength(120);
|
||||
entity.Property(item => item.Summary).HasMaxLength(240);
|
||||
});
|
||||
|
||||
SeedData.Apply(modelBuilder);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Data;
|
||||
|
||||
public static class OperationalTablesBootstrapper
|
||||
{
|
||||
public static Task EnsureAsync(AwardsDbContext db) =>
|
||||
db.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
ALTER TABLE "UserSessions"
|
||||
ADD COLUMN IF NOT EXISTS "CreatedFromIp" character varying(80) NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE "UserSessions"
|
||||
ADD COLUMN IF NOT EXISTS "UserAgent" character varying(400) NOT NULL DEFAULT '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "RiskFlags" (
|
||||
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"SeasonId" integer NULL,
|
||||
"TwitchUserId" character varying(120) NULL,
|
||||
"Source" character varying(80) NOT NULL,
|
||||
"Type" character varying(80) NOT NULL,
|
||||
"Severity" character varying(20) NOT NULL,
|
||||
"Status" character varying(20) NOT NULL,
|
||||
"Summary" character varying(240) NOT NULL,
|
||||
"CreatedFromIp" character varying(80) NOT NULL,
|
||||
"UserAgent" character varying(400) NOT NULL,
|
||||
"MetadataJson" text NOT NULL,
|
||||
"ReviewedByTwitchId" character varying(120) NULL,
|
||||
"CreatedAt" timestamp with time zone NOT NULL,
|
||||
"ReviewedAt" timestamp with time zone NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "IX_RiskFlags_Status_CreatedAt"
|
||||
ON "RiskFlags" ("Status", "CreatedAt" DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "IX_RiskFlags_SeasonId"
|
||||
ON "RiskFlags" ("SeasonId");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "AdminAuditEntries" (
|
||||
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
"AdminTwitchUserId" character varying(120) NOT NULL,
|
||||
"ActionType" character varying(80) NOT NULL,
|
||||
"EntityType" character varying(80) NOT NULL,
|
||||
"EntityId" character varying(120) NOT NULL,
|
||||
"Summary" character varying(240) NOT NULL,
|
||||
"MetadataJson" text NOT NULL,
|
||||
"CreatedAt" timestamp with time zone NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "IX_AdminAuditEntries_CreatedAt"
|
||||
ON "AdminAuditEntries" ("CreatedAt" DESC);
|
||||
""");
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Backend.Domain;
|
||||
|
||||
public sealed class AdminAuditEntry
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string AdminTwitchUserId { get; set; } = string.Empty;
|
||||
public string ActionType { get; set; } = string.Empty;
|
||||
public string EntityType { get; set; } = string.Empty;
|
||||
public string EntityId { get; set; } = string.Empty;
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
public string MetadataJson { get; set; } = "{}";
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Backend.Domain;
|
||||
|
||||
public sealed class RiskFlag
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int? SeasonId { get; set; }
|
||||
public Season? Season { get; set; }
|
||||
public string? TwitchUserId { get; set; }
|
||||
public string Source { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public string Severity { get; set; } = "medium";
|
||||
public string Status { get; set; } = "open";
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
public string CreatedFromIp { get; set; } = string.Empty;
|
||||
public string UserAgent { get; set; } = string.Empty;
|
||||
public string MetadataJson { get; set; } = "{}";
|
||||
public string? ReviewedByTwitchId { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset? ReviewedAt { get; set; }
|
||||
}
|
||||
@@ -7,6 +7,8 @@ public sealed class UserSession
|
||||
public string TwitchUserId { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string Role { get; set; } = "viewer";
|
||||
public string CreatedFromIp { get; set; } = string.Empty;
|
||||
public string UserAgent { get; set; } = string.Empty;
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset LastSeenAt { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
+358
-15
@@ -2,6 +2,7 @@ using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
var connectionString = builder.Configuration["VTSA_POSTGRES"]
|
||||
@@ -58,6 +59,79 @@ static async Task<UserSession?> ResolveSessionAsync(HttpContext context, AwardsD
|
||||
return session;
|
||||
}
|
||||
|
||||
static string ReadClientIp(HttpContext context) =>
|
||||
context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
|
||||
static string ReadUserAgent(HttpContext context)
|
||||
{
|
||||
var value = context.Request.Headers.UserAgent.ToString().Trim();
|
||||
return value.Length > 400 ? value[..400] : value;
|
||||
}
|
||||
|
||||
static void AddAuditEntry(
|
||||
AwardsDbContext db,
|
||||
string adminTwitchUserId,
|
||||
string actionType,
|
||||
string entityType,
|
||||
string entityId,
|
||||
string summary,
|
||||
object? metadata = null)
|
||||
{
|
||||
db.AdminAuditEntries.Add(new AdminAuditEntry
|
||||
{
|
||||
AdminTwitchUserId = adminTwitchUserId,
|
||||
ActionType = actionType,
|
||||
EntityType = entityType,
|
||||
EntityId = entityId,
|
||||
Summary = summary,
|
||||
MetadataJson = JsonSerializer.Serialize(metadata ?? new { }),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
}
|
||||
|
||||
static async Task AddRiskFlagIfMissingAsync(
|
||||
AwardsDbContext db,
|
||||
int? seasonId,
|
||||
string? twitchUserId,
|
||||
string source,
|
||||
string type,
|
||||
string severity,
|
||||
string summary,
|
||||
string createdFromIp,
|
||||
string userAgent,
|
||||
object? metadata = null)
|
||||
{
|
||||
var threshold = DateTimeOffset.UtcNow.AddHours(-6);
|
||||
var exists = await db.RiskFlags.AnyAsync(item =>
|
||||
item.Status == "open"
|
||||
&& item.Source == source
|
||||
&& item.Type == type
|
||||
&& item.TwitchUserId == twitchUserId
|
||||
&& item.CreatedFromIp == createdFromIp
|
||||
&& item.SeasonId == seasonId
|
||||
&& item.CreatedAt >= threshold);
|
||||
|
||||
if (exists)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
db.RiskFlags.Add(new RiskFlag
|
||||
{
|
||||
SeasonId = seasonId,
|
||||
TwitchUserId = twitchUserId,
|
||||
Source = source,
|
||||
Type = type,
|
||||
Severity = severity,
|
||||
Status = "open",
|
||||
Summary = summary,
|
||||
CreatedFromIp = createdFromIp,
|
||||
UserAgent = userAgent,
|
||||
MetadataJson = JsonSerializer.Serialize(metadata ?? new { }),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
}
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
@@ -86,15 +160,18 @@ using (var scope = app.Services.CreateScope())
|
||||
try
|
||||
{
|
||||
await SessionBootstrapper.EnsureAsync(db);
|
||||
await OperationalTablesBootstrapper.EnsureAsync(db);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// If the session table bootstrap fails, the rest of the API can still start.
|
||||
// If the operational table bootstrap fails, the rest of the API can still start.
|
||||
}
|
||||
}
|
||||
|
||||
app.MapPost("/api/auth/dev-login", async (LoginRequest request, AwardsDbContext db) =>
|
||||
app.MapPost("/api/auth/dev-login", async (HttpContext context, LoginRequest request, AwardsDbContext db) =>
|
||||
{
|
||||
var createdFromIp = ReadClientIp(context);
|
||||
var userAgent = ReadUserAgent(context);
|
||||
var session = new UserSession
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
@@ -102,11 +179,32 @@ app.MapPost("/api/auth/dev-login", async (LoginRequest request, AwardsDbContext
|
||||
TwitchUserId = request.TwitchUserId.Trim(),
|
||||
DisplayName = request.DisplayName.Trim(),
|
||||
Role = string.Equals(request.Role, "admin", StringComparison.OrdinalIgnoreCase) ? "admin" : "viewer",
|
||||
CreatedFromIp = createdFromIp,
|
||||
UserAgent = userAgent,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
LastSeenAt = DateTimeOffset.UtcNow,
|
||||
IsActive = true,
|
||||
};
|
||||
|
||||
var recentSessionsFromIp = await db.UserSessions.CountAsync(item =>
|
||||
item.CreatedFromIp == createdFromIp
|
||||
&& item.CreatedAt >= DateTimeOffset.UtcNow.AddMinutes(-15));
|
||||
|
||||
if (recentSessionsFromIp >= 3)
|
||||
{
|
||||
await AddRiskFlagIfMissingAsync(
|
||||
db,
|
||||
null,
|
||||
session.TwitchUserId,
|
||||
"login",
|
||||
"rapid_login_ip",
|
||||
"medium",
|
||||
"Mehrere neue Sessions wurden in kurzer Zeit von derselben IP erzeugt.",
|
||||
createdFromIp,
|
||||
userAgent,
|
||||
new { recentSessionsFromIp });
|
||||
}
|
||||
|
||||
db.UserSessions.Add(session);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
@@ -338,6 +436,25 @@ app.MapPost("/api/public/nominations", async (HttpContext context, CreateNominat
|
||||
return Results.BadRequest(new { message = "A logged in user is required to submit nominations." });
|
||||
}
|
||||
|
||||
var createdFromIp = ReadClientIp(context);
|
||||
var userAgent = ReadUserAgent(context);
|
||||
var existingNominationCount = await db.Nominations.CountAsync(item =>
|
||||
item.SeasonId == category.SeasonId
|
||||
&& item.CategoryId == category.Id
|
||||
&& item.SubmittedByTwitchId == submitterId);
|
||||
|
||||
var previousNominations = await db.Nominations
|
||||
.Where(item =>
|
||||
item.SeasonId == category.SeasonId
|
||||
&& item.CategoryId == category.Id
|
||||
&& item.SubmittedByTwitchId == submitterId)
|
||||
.ToArrayAsync();
|
||||
|
||||
if (previousNominations.Length > 0)
|
||||
{
|
||||
db.Nominations.RemoveRange(previousNominations);
|
||||
}
|
||||
|
||||
var records = distinctNominees.Select(name => new Nomination
|
||||
{
|
||||
SeasonId = category.SeasonId,
|
||||
@@ -348,9 +465,44 @@ app.MapPost("/api/public/nominations", async (HttpContext context, CreateNominat
|
||||
});
|
||||
|
||||
await db.Nominations.AddRangeAsync(records);
|
||||
|
||||
var recentNominationVolume = await db.Nominations.CountAsync(item =>
|
||||
item.SubmittedByTwitchId == submitterId
|
||||
&& item.CreatedAt >= DateTimeOffset.UtcNow.AddMinutes(-10));
|
||||
|
||||
if (existingNominationCount > 0)
|
||||
{
|
||||
await AddRiskFlagIfMissingAsync(
|
||||
db,
|
||||
category.SeasonId,
|
||||
submitterId,
|
||||
"nomination",
|
||||
"resubmitted_nomination",
|
||||
"low",
|
||||
"Ein User hat seine Nominierung in derselben Kategorie erneut eingereicht.",
|
||||
createdFromIp,
|
||||
userAgent,
|
||||
new { categoryId = category.Id, existingNominationCount });
|
||||
}
|
||||
|
||||
if (recentNominationVolume >= 10)
|
||||
{
|
||||
await AddRiskFlagIfMissingAsync(
|
||||
db,
|
||||
category.SeasonId,
|
||||
submitterId,
|
||||
"nomination",
|
||||
"rapid_nomination_burst",
|
||||
"high",
|
||||
"Ungewoehnlich viele Nominierungsaktionen in kurzer Zeit erkannt.",
|
||||
createdFromIp,
|
||||
userAgent,
|
||||
new { recentNominationVolume });
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { saved = distinctNominees.Length, category = category.Name });
|
||||
return Results.Ok(new { saved = distinctNominees.Length, category = category.Name, replacedPrevious = previousNominations.Length > 0 });
|
||||
})
|
||||
.WithName("CreateNomination")
|
||||
.WithOpenApi();
|
||||
@@ -379,24 +531,92 @@ app.MapPost("/api/public/votes", async (HttpContext context, CreateVoteRequest r
|
||||
return Results.BadRequest(new { message = "A logged in user is required to submit votes." });
|
||||
}
|
||||
|
||||
var ballot = new VoteBallot
|
||||
var createdFromIp = ReadClientIp(context);
|
||||
var userAgent = ReadUserAgent(context);
|
||||
var candidateIds = request.Entries.Select(item => item.CandidateId).Distinct().ToArray();
|
||||
var validCandidates = await db.Candidates
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == request.SeasonId && candidateIds.Contains(item.Id))
|
||||
.Select(item => new { item.Id, item.CategoryId })
|
||||
.ToArrayAsync();
|
||||
|
||||
if (validCandidates.Length != candidateIds.Length)
|
||||
{
|
||||
return Results.BadRequest(new { message = "One or more selected candidates do not belong to this season." });
|
||||
}
|
||||
|
||||
var candidateCategoryMap = validCandidates.ToDictionary(item => item.Id, item => item.CategoryId);
|
||||
if (request.Entries.Any(item => candidateCategoryMap[item.CandidateId] != item.CategoryId))
|
||||
{
|
||||
return Results.BadRequest(new { message = "A selected candidate does not match the submitted category." });
|
||||
}
|
||||
|
||||
var ballot = await db.VoteBallots
|
||||
.Include(item => item.Entries)
|
||||
.FirstOrDefaultAsync(item => item.SeasonId == request.SeasonId && item.SubmittedByTwitchId == submitterId);
|
||||
|
||||
var isResubmission = ballot is not null;
|
||||
if (ballot is null)
|
||||
{
|
||||
ballot = new VoteBallot
|
||||
{
|
||||
SeasonId = request.SeasonId,
|
||||
SubmittedByTwitchId = submitterId,
|
||||
SubmittedAt = DateTimeOffset.UtcNow,
|
||||
Status = "submitted",
|
||||
};
|
||||
|
||||
await db.VoteBallots.AddAsync(ballot);
|
||||
}
|
||||
else
|
||||
{
|
||||
db.VoteEntries.RemoveRange(ballot.Entries);
|
||||
ballot.Entries.Clear();
|
||||
}
|
||||
|
||||
ballot.SubmittedAt = DateTimeOffset.UtcNow;
|
||||
ballot.Status = "submitted";
|
||||
ballot.Entries = request.Entries.Select(entry => new VoteEntry
|
||||
{
|
||||
CategoryId = entry.CategoryId,
|
||||
CandidateId = entry.CandidateId,
|
||||
}).ToList();
|
||||
|
||||
await db.VoteBallots.AddAsync(ballot);
|
||||
var recentVoteSubmissions = await db.VoteBallots.CountAsync(item =>
|
||||
item.SubmittedByTwitchId == submitterId
|
||||
&& item.SubmittedAt >= DateTimeOffset.UtcNow.AddMinutes(-10));
|
||||
|
||||
if (isResubmission)
|
||||
{
|
||||
await AddRiskFlagIfMissingAsync(
|
||||
db,
|
||||
request.SeasonId,
|
||||
submitterId,
|
||||
"vote",
|
||||
"resubmitted_ballot",
|
||||
"low",
|
||||
"Ein User hat sein Ballot erneut gespeichert oder aktualisiert.",
|
||||
createdFromIp,
|
||||
userAgent,
|
||||
new { entryCount = request.Entries.Length });
|
||||
}
|
||||
|
||||
if (recentVoteSubmissions >= 3)
|
||||
{
|
||||
await AddRiskFlagIfMissingAsync(
|
||||
db,
|
||||
request.SeasonId,
|
||||
submitterId,
|
||||
"vote",
|
||||
"rapid_vote_updates",
|
||||
"high",
|
||||
"Mehrere Voting-Aenderungen wurden in kurzer Zeit erkannt.",
|
||||
createdFromIp,
|
||||
userAgent,
|
||||
new { recentVoteSubmissions });
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { ballotId = ballot.Id, entries = ballot.Entries.Count });
|
||||
return Results.Ok(new { ballotId = ballot.Id, entries = ballot.Entries.Count, updated = isResubmission });
|
||||
})
|
||||
.WithName("CreateVote")
|
||||
.WithOpenApi();
|
||||
@@ -433,6 +653,43 @@ app.MapGet("/api/admin/dashboard", async (HttpContext context, AwardsDbContext d
|
||||
.Take(5)
|
||||
.ToArray();
|
||||
|
||||
var riskFlags = await db.RiskFlags
|
||||
.AsNoTracking()
|
||||
.Where(item => item.Status == "open")
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(8)
|
||||
.Select(item => new AdminRiskFlagDto(
|
||||
item.Id,
|
||||
item.Source,
|
||||
item.Type,
|
||||
item.Severity,
|
||||
item.Status,
|
||||
item.Summary,
|
||||
item.TwitchUserId,
|
||||
item.CreatedFromIp,
|
||||
item.CreatedAt,
|
||||
item.MetadataJson))
|
||||
.ToArrayAsync();
|
||||
|
||||
var auditEntries = await db.AdminAuditEntries
|
||||
.AsNoTracking()
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(8)
|
||||
.Select(item => new AdminAuditEntryDto(
|
||||
item.Id,
|
||||
item.AdminTwitchUserId,
|
||||
item.ActionType,
|
||||
item.EntityType,
|
||||
item.EntityId,
|
||||
item.Summary,
|
||||
item.CreatedAt))
|
||||
.ToArrayAsync();
|
||||
|
||||
var activityItems = auditEntries
|
||||
.Take(3)
|
||||
.Select(item => new AdminActivityDto(item.Summary, $"{Math.Max(1, (int)Math.Round((DateTimeOffset.UtcNow - item.CreatedAt).TotalMinutes))} Min."))
|
||||
.ToArray();
|
||||
|
||||
var response = new AdminDashboardResponse(
|
||||
new[]
|
||||
{
|
||||
@@ -441,13 +698,10 @@ app.MapGet("/api/admin/dashboard", async (HttpContext context, AwardsDbContext d
|
||||
new AdminMetricDto("Kategorien", categoryCount, "aktiv im aktuellen Jahr"),
|
||||
new AdminMetricDto("Reviews offen", reviewCount, "Freitext und Dubletten"),
|
||||
},
|
||||
new[]
|
||||
{
|
||||
new AdminActivityDto("Neue Nominierung in Best New VTuber", "vor 2 Min."),
|
||||
new AdminActivityDto("Clip-Dublette erkannt in Clip des Jahres", "vor 7 Min."),
|
||||
new AdminActivityDto("Alias-Merge fuer Hoshimi Miyu reviewt", "vor 18 Min."),
|
||||
},
|
||||
topCategories);
|
||||
activityItems,
|
||||
topCategories,
|
||||
riskFlags,
|
||||
auditEntries);
|
||||
|
||||
return Results.Ok(response);
|
||||
})
|
||||
@@ -594,6 +848,14 @@ app.MapPut("/api/admin/seasons/{seasonId:int}", async (HttpContext context, int
|
||||
}
|
||||
|
||||
season.IsCurrent = request.IsCurrent;
|
||||
AddAuditEntry(
|
||||
db,
|
||||
session.TwitchUserId,
|
||||
"season.update",
|
||||
"season",
|
||||
season.Id.ToString(),
|
||||
$"Season {season.Year} wurde aktualisiert.",
|
||||
new { request.CurrentPhase, request.IsCurrent });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { saved = true, seasonId = season.Id });
|
||||
@@ -627,6 +889,14 @@ app.MapPost("/api/admin/seasons/{seasonId:int}/categories", async (HttpContext c
|
||||
};
|
||||
|
||||
db.Categories.Add(category);
|
||||
AddAuditEntry(
|
||||
db,
|
||||
session.TwitchUserId,
|
||||
"category.create",
|
||||
"category",
|
||||
request.Slug.Trim(),
|
||||
$"Kategorie {request.Name.Trim()} wurde angelegt.",
|
||||
new { seasonId, request.GroupName, request.SortOrder });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { saved = true, categoryId = category.Id });
|
||||
@@ -655,6 +925,14 @@ app.MapPut("/api/admin/categories/{categoryId:int}", async (HttpContext context,
|
||||
category.SortOrder = request.SortOrder;
|
||||
category.MaxNomineesPerUser = request.MaxNomineesPerUser;
|
||||
|
||||
AddAuditEntry(
|
||||
db,
|
||||
session.TwitchUserId,
|
||||
"category.update",
|
||||
"category",
|
||||
category.Id.ToString(),
|
||||
$"Kategorie {request.Name.Trim()} wurde aktualisiert.",
|
||||
new { request.GroupName, request.SortOrder, request.MaxNomineesPerUser });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { saved = true, categoryId = category.Id });
|
||||
@@ -686,6 +964,14 @@ app.MapPost("/api/admin/seasons/{seasonId:int}/candidates", async (HttpContext c
|
||||
};
|
||||
|
||||
db.Candidates.Add(candidate);
|
||||
AddAuditEntry(
|
||||
db,
|
||||
session.TwitchUserId,
|
||||
"candidate.create",
|
||||
"candidate",
|
||||
request.DisplayName.Trim(),
|
||||
$"Kandidat {request.DisplayName.Trim()} wurde angelegt.",
|
||||
new { seasonId, request.CategoryId, request.Platform });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { saved = true, candidateId = candidate.Id });
|
||||
@@ -712,6 +998,14 @@ app.MapPut("/api/admin/candidates/{candidateId:int}", async (HttpContext context
|
||||
candidate.ChannelSlug = request.ChannelSlug.Trim();
|
||||
candidate.Platform = request.Platform.Trim();
|
||||
|
||||
AddAuditEntry(
|
||||
db,
|
||||
session.TwitchUserId,
|
||||
"candidate.update",
|
||||
"candidate",
|
||||
candidate.Id.ToString(),
|
||||
$"Kandidat {request.DisplayName.Trim()} wurde aktualisiert.",
|
||||
new { request.CategoryId, request.Platform });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { saved = true, candidateId = candidate.Id });
|
||||
@@ -783,6 +1077,14 @@ app.MapPost("/api/admin/nominations/{nominationId:int}/approve", async (HttpCont
|
||||
|
||||
nomination.CandidateId = candidate.Id;
|
||||
nomination.CandidateText = null;
|
||||
AddAuditEntry(
|
||||
db,
|
||||
session.TwitchUserId,
|
||||
"nomination.approve",
|
||||
"nomination",
|
||||
nomination.Id.ToString(),
|
||||
$"Nominierung {nomination.Id} wurde als Kandidat uebernommen.",
|
||||
new { candidateId = candidate.Id, created = existingCandidate is null });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { saved = true, nominationId = nomination.Id, candidateId = candidate.Id, created = existingCandidate is null });
|
||||
@@ -806,6 +1108,13 @@ app.MapPost("/api/admin/nominations/{nominationId:int}/reject", async (HttpConte
|
||||
|
||||
nomination.CandidateText = null;
|
||||
nomination.CandidateId = null;
|
||||
AddAuditEntry(
|
||||
db,
|
||||
session.TwitchUserId,
|
||||
"nomination.reject",
|
||||
"nomination",
|
||||
nomination.Id.ToString(),
|
||||
$"Nominierung {nomination.Id} wurde verworfen.");
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { saved = true, nominationId = nomination.Id, rejected = true });
|
||||
@@ -813,4 +1122,38 @@ app.MapPost("/api/admin/nominations/{nominationId:int}/reject", async (HttpConte
|
||||
.WithName("RejectAdminNomination")
|
||||
.WithOpenApi();
|
||||
|
||||
app.MapPost("/api/admin/risk-flags/{riskFlagId:int}/resolve", async (HttpContext context, int riskFlagId, ResolveRiskFlagRequest request, AwardsDbContext db) =>
|
||||
{
|
||||
var session = await ResolveSessionAsync(context, db);
|
||||
if (session?.Role != "admin")
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var riskFlag = await db.RiskFlags.FirstOrDefaultAsync(item => item.Id == riskFlagId);
|
||||
if (riskFlag is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
riskFlag.Status = string.IsNullOrWhiteSpace(request.Status) ? "resolved" : request.Status.Trim().ToLowerInvariant();
|
||||
riskFlag.ReviewedAt = DateTimeOffset.UtcNow;
|
||||
riskFlag.ReviewedByTwitchId = session.TwitchUserId;
|
||||
|
||||
AddAuditEntry(
|
||||
db,
|
||||
session.TwitchUserId,
|
||||
"risk.resolve",
|
||||
"risk-flag",
|
||||
riskFlag.Id.ToString(),
|
||||
$"Risk Flag {riskFlag.Id} wurde als {riskFlag.Status} markiert.",
|
||||
new { riskFlag.Type, riskFlag.Source });
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { saved = true, riskFlagId = riskFlag.Id, status = riskFlag.Status });
|
||||
})
|
||||
.WithName("ResolveRiskFlag")
|
||||
.WithOpenApi();
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -28,6 +28,7 @@ The frontend uses a lightweight local session flow for development:
|
||||
- Sign in from the header
|
||||
- `Viewer Login` unlocks nomination and voting
|
||||
- `Admin Login` unlocks the admin routes and management views
|
||||
- Voting and nominations can be resubmitted; the backend updates the existing user state instead of blindly duplicating submissions
|
||||
|
||||
## Backend
|
||||
|
||||
@@ -85,3 +86,4 @@ curl -X POST http://localhost:5084/api/auth/dev-login \
|
||||
- Session endpoints live under `/api/auth/*`
|
||||
- Database connectivity and pending migrations are exposed at `/api/health/database`
|
||||
- Current frontend store falls back to static seed-like data if the API is unavailable
|
||||
- The admin dashboard now includes a lightweight risk center and audit log for suspicious submit patterns and reviewed admin actions
|
||||
|
||||
@@ -25,7 +25,7 @@ const navItems = [
|
||||
]
|
||||
|
||||
const currentLabel = computed(
|
||||
() => navItems.find((item) => item.to === route.path)?.label ?? 'Awards',
|
||||
() => navItems.find((item) => route.path === item.to || route.path.startsWith(`${item.to}/`))?.label ?? 'Awards',
|
||||
)
|
||||
|
||||
const visibleNavItems = computed(() =>
|
||||
@@ -103,7 +103,7 @@ async function login(role: 'viewer' | 'admin') {
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="rounded-full px-4 py-2 transition hover:bg-violet-50 hover:text-violet-700"
|
||||
:class="route.path === item.to ? 'bg-violet-100 text-violet-800' : ''"
|
||||
:class="route.path === item.to || route.path.startsWith(`${item.to}/`) ? 'bg-violet-100 text-violet-800' : ''"
|
||||
>
|
||||
{{ item.label }}
|
||||
</RouterLink>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import Select from 'primevue/select'
|
||||
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
|
||||
const store = useAwardsStore()
|
||||
|
||||
const seasons = computed(() => store.adminSeasons)
|
||||
const selectedSeasonId = computed({
|
||||
get: () => store.adminSelectedSeasonId,
|
||||
set: async (value) => {
|
||||
if (!value) return
|
||||
await store.loadAdminSeasonDetail(value)
|
||||
},
|
||||
})
|
||||
|
||||
const seasonOptions = computed(() =>
|
||||
seasons.value.map((season) => ({
|
||||
label: `${season.year} · ${season.name}`,
|
||||
value: season.id,
|
||||
})),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 rounded-[26px] border border-violet-100 bg-white/80 px-5 py-5 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-violet-500">Arbeitskontext</p>
|
||||
<p class="mt-2 text-sm text-slate-500">Die gewaehlte Season steuert Kategorien, Kandidaten und Review-Queues im gesamten Admin-Bereich.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:min-w-[330px]">
|
||||
<label class="text-sm font-semibold text-slate-600">Season</label>
|
||||
<Select
|
||||
v-model="selectedSeasonId"
|
||||
:options="seasonOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -96,6 +96,12 @@ export const api = {
|
||||
'POST',
|
||||
{},
|
||||
),
|
||||
resolveRiskFlag: (riskFlagId: number, status = 'resolved') =>
|
||||
sendJson<{ saved: boolean; riskFlagId: number; status: string }>(
|
||||
`/api/admin/risk-flags/${riskFlagId}/resolve`,
|
||||
'POST',
|
||||
{ status },
|
||||
),
|
||||
}
|
||||
|
||||
export { AUTH_TOKEN_KEY }
|
||||
|
||||
+38
-3
@@ -1,7 +1,12 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
|
||||
import AdminView from './views/AdminView.vue'
|
||||
import AdminCandidatesView from './views/admin/AdminCandidatesView.vue'
|
||||
import AdminDashboardView from './views/admin/AdminDashboardView.vue'
|
||||
import AdminLayoutView from './views/admin/AdminLayoutView.vue'
|
||||
import AdminReviewsView from './views/admin/AdminReviewsView.vue'
|
||||
import AdminRiskView from './views/admin/AdminRiskView.vue'
|
||||
import AdminSeasonsView from './views/admin/AdminSeasonsView.vue'
|
||||
import HomeView from './views/HomeView.vue'
|
||||
import NominationsView from './views/NominationsView.vue'
|
||||
import VotingView from './views/VotingView.vue'
|
||||
@@ -41,11 +46,41 @@ const router = createRouter({
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
name: 'admin',
|
||||
component: AdminView,
|
||||
component: AdminLayoutView,
|
||||
meta: {
|
||||
requiresAdmin: true,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirect: { name: 'admin-dashboard' },
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'admin-dashboard',
|
||||
component: AdminDashboardView,
|
||||
},
|
||||
{
|
||||
path: 'seasons',
|
||||
name: 'admin-seasons',
|
||||
component: AdminSeasonsView,
|
||||
},
|
||||
{
|
||||
path: 'candidates',
|
||||
name: 'admin-candidates',
|
||||
component: AdminCandidatesView,
|
||||
},
|
||||
{
|
||||
path: 'reviews',
|
||||
name: 'admin-reviews',
|
||||
component: AdminReviewsView,
|
||||
},
|
||||
{
|
||||
path: 'risk',
|
||||
name: 'admin-risk',
|
||||
component: AdminRiskView,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -103,6 +103,31 @@ const fallbackAdmin: AdminDashboardResponse = {
|
||||
{ category: 'Bestes Live Event', votes: 132550 },
|
||||
{ category: 'Clip des Jahres', votes: 98210 },
|
||||
],
|
||||
riskFlags: [
|
||||
{
|
||||
id: 1,
|
||||
source: 'vote',
|
||||
type: 'rapid_vote_updates',
|
||||
severity: 'high',
|
||||
status: 'open',
|
||||
summary: 'Mehrere Voting-Aenderungen in kurzer Zeit erkannt.',
|
||||
twitchUserId: 'demo_user',
|
||||
createdFromIp: '127.0.0.1',
|
||||
createdAt: '2026-06-17T08:40:00Z',
|
||||
metadataJson: '{"recentVoteSubmissions":3}',
|
||||
},
|
||||
],
|
||||
auditEntries: [
|
||||
{
|
||||
id: 1,
|
||||
adminTwitchUserId: 'jayuhime_admin',
|
||||
actionType: 'category.update',
|
||||
entityType: 'category',
|
||||
entityId: '1',
|
||||
summary: 'Kategorie VTuber des Jahres wurde aktualisiert.',
|
||||
createdAt: '2026-06-17T08:32:00Z',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const fallbackAdminSeasons: AdminSeasonListItem[] = [
|
||||
@@ -158,6 +183,8 @@ const emptyAdmin: AdminDashboardResponse = {
|
||||
metrics: [],
|
||||
activities: [],
|
||||
topCategories: [],
|
||||
riskFlags: [],
|
||||
auditEntries: [],
|
||||
}
|
||||
|
||||
const emptyAdminSeasons: AdminSeasonListItem[] = []
|
||||
@@ -181,6 +208,7 @@ export const useAwardsStore = defineStore('awards', {
|
||||
admin: fallbackAdmin as AdminDashboardResponse,
|
||||
adminSeasons: fallbackAdminSeasons as AdminSeasonListItem[],
|
||||
adminSeasonDetail: fallbackAdminSeasonDetail as AdminSeasonDetailResponse,
|
||||
adminSelectedSeasonId: fallbackAdminSeasonDetail.id as number | null,
|
||||
loading: false,
|
||||
apiMode: 'fallback' as 'api' | 'fallback',
|
||||
}),
|
||||
@@ -210,22 +238,39 @@ export const useAwardsStore = defineStore('awards', {
|
||||
try {
|
||||
this.admin = await api.getAdminDashboard()
|
||||
this.adminSeasons = await api.getAdminSeasons()
|
||||
this.adminSeasonDetail = await api.getAdminSeasonDetail(this.adminSeasons[0]?.id ?? 1)
|
||||
if (!this.adminSelectedSeasonId || !this.adminSeasons.some((season) => season.id === this.adminSelectedSeasonId)) {
|
||||
this.adminSelectedSeasonId = this.adminSeasons[0]?.id ?? null
|
||||
}
|
||||
|
||||
if (this.adminSelectedSeasonId) {
|
||||
this.adminSeasonDetail = await api.getAdminSeasonDetail(this.adminSelectedSeasonId)
|
||||
}
|
||||
this.apiMode = 'api'
|
||||
} catch {
|
||||
this.admin = emptyAdmin
|
||||
this.adminSeasons = emptyAdminSeasons
|
||||
this.adminSeasonDetail = emptyAdminSeasonDetail
|
||||
this.adminSelectedSeasonId = null
|
||||
}
|
||||
},
|
||||
async loadAdminSeasonDetail(seasonId: number) {
|
||||
try {
|
||||
this.adminSelectedSeasonId = seasonId
|
||||
this.adminSeasonDetail = await api.getAdminSeasonDetail(seasonId)
|
||||
this.apiMode = 'api'
|
||||
} catch {
|
||||
this.adminSeasonDetail = emptyAdminSeasonDetail
|
||||
}
|
||||
},
|
||||
async initializeAdminWorkspace() {
|
||||
await this.loadAdmin()
|
||||
if (this.adminSelectedSeasonId && this.adminSeasonDetail.id !== this.adminSelectedSeasonId) {
|
||||
await this.loadAdminSeasonDetail(this.adminSelectedSeasonId)
|
||||
}
|
||||
},
|
||||
setAdminSeason(seasonId: number) {
|
||||
this.adminSelectedSeasonId = seasonId
|
||||
},
|
||||
submitNomination(payload: CreateNominationPayload) {
|
||||
return api.submitNomination(payload)
|
||||
},
|
||||
@@ -269,5 +314,10 @@ export const useAwardsStore = defineStore('awards', {
|
||||
await this.loadAdmin()
|
||||
return result
|
||||
},
|
||||
async resolveRiskFlag(riskFlagId: number, status = 'resolved') {
|
||||
const result = await api.resolveRiskFlag(riskFlagId, status)
|
||||
await this.loadAdmin()
|
||||
return result
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -89,10 +89,35 @@ export interface AdminTopCategory {
|
||||
votes: number
|
||||
}
|
||||
|
||||
export interface AdminRiskFlag {
|
||||
id: number
|
||||
source: string
|
||||
type: string
|
||||
severity: string
|
||||
status: string
|
||||
summary: string
|
||||
twitchUserId: string | null
|
||||
createdFromIp: string
|
||||
createdAt: string
|
||||
metadataJson: string
|
||||
}
|
||||
|
||||
export interface AdminAuditEntry {
|
||||
id: number
|
||||
adminTwitchUserId: string
|
||||
actionType: string
|
||||
entityType: string
|
||||
entityId: string
|
||||
summary: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface AdminDashboardResponse {
|
||||
metrics: AdminMetric[]
|
||||
activities: AdminActivity[]
|
||||
topCategories: AdminTopCategory[]
|
||||
riskFlags: AdminRiskFlag[]
|
||||
auditEntries: AdminAuditEntry[]
|
||||
}
|
||||
|
||||
export interface AdminSeasonListItem {
|
||||
|
||||
@@ -1,560 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import Column from 'primevue/column'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Select from 'primevue/select'
|
||||
|
||||
import Button from '../components/ui/Button.vue'
|
||||
import Card from '../components/ui/Card.vue'
|
||||
import { useAwardsStore } from '../stores/awards'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const store = useAwardsStore()
|
||||
const authStore = useAuthStore()
|
||||
const selectedSeasonId = ref<number | null>(null)
|
||||
const seasonSaving = ref(false)
|
||||
const categorySaving = ref<number | 'new' | null>(null)
|
||||
const candidateSaving = ref<number | 'new' | null>(null)
|
||||
const reviewSaving = ref<number | null>(null)
|
||||
const adminMessage = ref('')
|
||||
const adminError = ref('')
|
||||
|
||||
const seasonForm = reactive({
|
||||
currentPhase: '',
|
||||
isCurrent: false,
|
||||
})
|
||||
|
||||
const newCategoryForm = reactive({
|
||||
groupName: '',
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
sortOrder: 1,
|
||||
maxNomineesPerUser: 3,
|
||||
})
|
||||
|
||||
const newCandidateForm = reactive({
|
||||
categoryId: 0,
|
||||
displayName: '',
|
||||
channelSlug: '',
|
||||
platform: 'Twitch',
|
||||
})
|
||||
|
||||
const editForms = reactive<Record<number, {
|
||||
groupName: string
|
||||
name: string
|
||||
slug: string
|
||||
description: string
|
||||
sortOrder: number
|
||||
maxNomineesPerUser: number
|
||||
}>>({})
|
||||
|
||||
const candidateForms = reactive<Record<number, {
|
||||
categoryId: number
|
||||
displayName: string
|
||||
channelSlug: string
|
||||
platform: string
|
||||
}>>({})
|
||||
|
||||
const reviewForms = reactive<Record<number, {
|
||||
displayName: string
|
||||
channelSlug: string
|
||||
platform: string
|
||||
}>>({})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!authStore.isAdmin) return
|
||||
await store.loadAdmin()
|
||||
selectedSeasonId.value = store.adminSeasons[0]?.id ?? null
|
||||
})
|
||||
|
||||
const metrics = computed(() => store.admin.metrics)
|
||||
const activities = computed(() => store.admin.activities)
|
||||
const topCategories = computed(() => store.admin.topCategories)
|
||||
const seasons = computed(() => store.adminSeasons)
|
||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||
const categoryOptions = computed(() =>
|
||||
seasonDetail.value.categories.map((category) => ({
|
||||
label: `${category.groupName} · ${category.name}`,
|
||||
value: category.id,
|
||||
})),
|
||||
)
|
||||
|
||||
watch(selectedSeasonId, async (seasonId) => {
|
||||
if (!seasonId) return
|
||||
await store.loadAdminSeasonDetail(seasonId)
|
||||
})
|
||||
|
||||
watch(
|
||||
seasonDetail,
|
||||
(detail) => {
|
||||
seasonForm.currentPhase = detail.currentPhase
|
||||
seasonForm.isCurrent = detail.isCurrent
|
||||
|
||||
for (const category of detail.categories) {
|
||||
editForms[category.id] = {
|
||||
groupName: category.groupName,
|
||||
name: category.name,
|
||||
slug: category.slug,
|
||||
description: category.description,
|
||||
sortOrder: category.sortOrder,
|
||||
maxNomineesPerUser: category.maxNomineesPerUser,
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of detail.candidates) {
|
||||
candidateForms[candidate.id] = {
|
||||
categoryId: candidate.categoryId,
|
||||
displayName: candidate.displayName,
|
||||
channelSlug: candidate.channelSlug,
|
||||
platform: candidate.platform,
|
||||
}
|
||||
}
|
||||
|
||||
for (const nomination of detail.pendingNominations) {
|
||||
reviewForms[nomination.id] = {
|
||||
displayName: nomination.candidateText,
|
||||
channelSlug: '',
|
||||
platform: 'Twitch',
|
||||
}
|
||||
}
|
||||
|
||||
newCandidateForm.categoryId = detail.categories[0]?.id ?? 0
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const seasonOptions = computed(() =>
|
||||
seasons.value.map((season) => ({
|
||||
label: `${season.year} · ${season.name}`,
|
||||
value: season.id,
|
||||
})),
|
||||
)
|
||||
|
||||
async function saveSeason() {
|
||||
if (!selectedSeasonId.value) return
|
||||
|
||||
seasonSaving.value = true
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
await store.updateAdminSeason(selectedSeasonId.value, {
|
||||
currentPhase: seasonForm.currentPhase,
|
||||
isCurrent: seasonForm.isCurrent,
|
||||
})
|
||||
await store.loadAdminSeasonDetail(selectedSeasonId.value)
|
||||
adminMessage.value = 'Season-Einstellungen gespeichert.'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Season konnte nicht gespeichert werden.'
|
||||
} finally {
|
||||
seasonSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCategory(categoryId: number) {
|
||||
if (!selectedSeasonId.value) return
|
||||
|
||||
categorySaving.value = categoryId
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
await store.updateAdminCategory(categoryId, selectedSeasonId.value, editForms[categoryId])
|
||||
adminMessage.value = 'Kategorie gespeichert.'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Kategorie konnte nicht gespeichert werden.'
|
||||
} finally {
|
||||
categorySaving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function createCategory() {
|
||||
if (!selectedSeasonId.value) return
|
||||
|
||||
categorySaving.value = 'new'
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
await store.createAdminCategory(selectedSeasonId.value, newCategoryForm)
|
||||
adminMessage.value = 'Neue Kategorie angelegt.'
|
||||
newCategoryForm.groupName = ''
|
||||
newCategoryForm.name = ''
|
||||
newCategoryForm.slug = ''
|
||||
newCategoryForm.description = ''
|
||||
newCategoryForm.sortOrder = seasonDetail.value.categories.length + 1
|
||||
newCategoryForm.maxNomineesPerUser = 3
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Kategorie konnte nicht angelegt werden.'
|
||||
} finally {
|
||||
categorySaving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCandidate(candidateId: number) {
|
||||
if (!selectedSeasonId.value) return
|
||||
|
||||
candidateSaving.value = candidateId
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
await store.updateAdminCandidate(candidateId, selectedSeasonId.value, candidateForms[candidateId])
|
||||
adminMessage.value = 'Kandidat gespeichert.'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Kandidat konnte nicht gespeichert werden.'
|
||||
} finally {
|
||||
candidateSaving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function createCandidate() {
|
||||
if (!selectedSeasonId.value || !newCandidateForm.categoryId) return
|
||||
|
||||
candidateSaving.value = 'new'
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
await store.createAdminCandidate(selectedSeasonId.value, newCandidateForm)
|
||||
adminMessage.value = 'Kandidat angelegt.'
|
||||
newCandidateForm.displayName = ''
|
||||
newCandidateForm.channelSlug = ''
|
||||
newCandidateForm.platform = 'Twitch'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Kandidat konnte nicht angelegt werden.'
|
||||
} finally {
|
||||
candidateSaving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function approveNomination(nominationId: number) {
|
||||
if (!selectedSeasonId.value) return
|
||||
|
||||
reviewSaving.value = nominationId
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
await store.approveAdminNomination(nominationId, selectedSeasonId.value, reviewForms[nominationId])
|
||||
adminMessage.value = 'Nominierung wurde in die Kandidatenliste uebernommen.'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht uebernommen werden.'
|
||||
} finally {
|
||||
reviewSaving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectNomination(nominationId: number) {
|
||||
if (!selectedSeasonId.value) return
|
||||
|
||||
reviewSaving.value = nominationId
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
await store.rejectAdminNomination(nominationId, selectedSeasonId.value)
|
||||
adminMessage.value = 'Nominierung wurde aus der Review Queue entfernt.'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht verworfen werden.'
|
||||
} finally {
|
||||
reviewSaving.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-10 pb-14">
|
||||
<Card v-if="!authStore.isAdmin" class="p-8">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Admin Access</p>
|
||||
<h1 class="mt-3 font-[Cormorant_Garamond] text-5xl text-violet-800">Admin Login erforderlich</h1>
|
||||
<p class="mt-4 max-w-2xl text-lg leading-8 text-slate-600">
|
||||
Bitte melde dich ueber den Header mit einem Admin-Login an, damit Season-, Category-, Candidate- und Review-Management verfuegbar werden.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<template v-else>
|
||||
<div class="space-y-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Admin</p>
|
||||
<h1 class="max-w-[13ch] font-[Cormorant_Garamond] text-6xl leading-[0.92] text-violet-800">Betriebswerkzeug fuer Seasons, Kategorien, Kandidaten und Review-Flows</h1>
|
||||
<p class="max-w-3xl text-lg leading-8 text-slate-600">
|
||||
Das Team pflegt das Jahres-Setup und die operativen Awards-Inhalte direkt aus einer zusammenhaengenden Admin-Oberflaeche.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-5 lg:grid-cols-4">
|
||||
<Card
|
||||
v-for="metric in metrics"
|
||||
:key="metric.label"
|
||||
class="p-7"
|
||||
>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">{{ metric.label }}</p>
|
||||
<strong class="mt-4 block text-4xl text-violet-800">{{ metric.value.toLocaleString('de-DE') }}</strong>
|
||||
<p class="mt-2 text-sm text-slate-500">{{ metric.note }}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[0.9fr_1.1fr]">
|
||||
<Card class="p-7">
|
||||
<div class="flex flex-col gap-6">
|
||||
<div>
|
||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Season Setup</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">Aktive Season auswaehlen, Phase anpassen und bei Bedarf zum aktuellen Jahr machen.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<label class="text-sm font-semibold text-slate-600">Season</label>
|
||||
<Select
|
||||
v-model="selectedSeasonId"
|
||||
:options="seasonOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<label class="text-sm font-semibold text-slate-600">Phase</label>
|
||||
<input
|
||||
v-model="seasonForm.currentPhase"
|
||||
type="text"
|
||||
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-3 rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-4 text-sm text-slate-700">
|
||||
<input v-model="seasonForm.isCurrent" type="checkbox" class="h-4 w-4 accent-violet-600" />
|
||||
Diese Season ist die aktuelle Public Season
|
||||
</label>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<Button :disabled="seasonSaving || !selectedSeasonId" @click="saveSeason">
|
||||
{{ seasonSaving ? 'Speichert ...' : 'Season speichern' }}
|
||||
</Button>
|
||||
<span class="text-sm text-slate-500">
|
||||
{{ seasonDetail.year }} · {{ seasonDetail.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="adminMessage" class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
|
||||
{{ adminMessage }}
|
||||
</p>
|
||||
<p v-if="adminError" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||
{{ adminError }}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-7">
|
||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Top Kategorien nach Votes</h2>
|
||||
<DataTable :value="topCategories" class="mt-6" striped-rows>
|
||||
<Column field="category" header="Kategorie" />
|
||||
<Column field="votes" header="Votes">
|
||||
<template #body="{ data }">
|
||||
{{ Number(data.votes).toLocaleString('de-DE') }}
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
|
||||
<Card class="p-7">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Kategorien der Season</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">Sortierung, Slugs und Limits werden hier pro Jahr gepflegt.</p>
|
||||
</div>
|
||||
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
||||
{{ seasonDetail.categories.length }} Kategorien
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<div
|
||||
v-for="category in seasonDetail.categories"
|
||||
:key="category.id"
|
||||
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
|
||||
>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<input v-model="editForms[category.id].groupName" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Group" />
|
||||
<input v-model="editForms[category.id].name" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Name" />
|
||||
<input v-model="editForms[category.id].slug" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Slug" />
|
||||
<input v-model="editForms[category.id].sortOrder" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Sortierung" />
|
||||
<input v-model="editForms[category.id].maxNomineesPerUser" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Limit" />
|
||||
<div class="flex items-center rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
|
||||
{{ category.candidateCount }} Kandidaten in dieser Kategorie
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-model="editForms[category.id].description"
|
||||
class="mt-4 min-h-28 w-full rounded-2xl border border-violet-200 px-4 py-3"
|
||||
placeholder="Beschreibung"
|
||||
/>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Button :disabled="categorySaving === category.id" @click="saveCategory(category.id)">
|
||||
{{ categorySaving === category.id ? 'Speichert ...' : 'Kategorie speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-7">
|
||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Neue Kategorie</h2>
|
||||
<div class="mt-6 space-y-4">
|
||||
<input v-model="newCategoryForm.groupName" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Group Name" />
|
||||
<input v-model="newCategoryForm.name" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Kategorie-Name" />
|
||||
<input v-model="newCategoryForm.slug" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Slug" />
|
||||
<textarea v-model="newCategoryForm.description" class="min-h-28 w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Beschreibung" />
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<input v-model="newCategoryForm.sortOrder" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Sortierung" />
|
||||
<input v-model="newCategoryForm.maxNomineesPerUser" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Max Nominees" />
|
||||
</div>
|
||||
<Button :disabled="categorySaving === 'new' || !selectedSeasonId" @click="createCategory">
|
||||
{{ categorySaving === 'new' ? 'Erstellt ...' : 'Kategorie anlegen' }}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
||||
<Card class="p-7">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Kandidatenpflege</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">Bekannte Kandidaten koennen pro Kategorie gepflegt und fuer Voting/Archiv genutzt werden.</p>
|
||||
</div>
|
||||
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
||||
{{ seasonDetail.candidates.length }} Kandidaten
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<div
|
||||
v-for="candidate in seasonDetail.candidates"
|
||||
:key="candidate.id"
|
||||
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
|
||||
>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<Select
|
||||
v-model="candidateForms[candidate.id].categoryId"
|
||||
:options="categoryOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="w-full"
|
||||
/>
|
||||
<input v-model="candidateForms[candidate.id].displayName" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Display Name" />
|
||||
<input v-model="candidateForms[candidate.id].channelSlug" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="@channel" />
|
||||
<input v-model="candidateForms[candidate.id].platform" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Platform" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Button :disabled="candidateSaving === candidate.id" @click="saveCandidate(candidate.id)">
|
||||
{{ candidateSaving === candidate.id ? 'Speichert ...' : 'Kandidat speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-7">
|
||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Neuer Kandidat</h2>
|
||||
<div class="mt-6 space-y-4">
|
||||
<Select
|
||||
v-model="newCandidateForm.categoryId"
|
||||
:options="categoryOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="w-full"
|
||||
/>
|
||||
<input v-model="newCandidateForm.displayName" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Display Name" />
|
||||
<input v-model="newCandidateForm.channelSlug" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="@channel" />
|
||||
<input v-model="newCandidateForm.platform" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Platform" />
|
||||
<Button :disabled="candidateSaving === 'new' || !selectedSeasonId || !newCandidateForm.categoryId" @click="createCandidate">
|
||||
{{ candidateSaving === 'new' ? 'Erstellt ...' : 'Kandidat anlegen' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<h3 class="mt-10 font-[Cormorant_Garamond] text-3xl text-violet-800">Letzte Aktivitaeten</h3>
|
||||
<div class="mt-4 space-y-4">
|
||||
<div
|
||||
v-for="activity in activities"
|
||||
:key="activity.label"
|
||||
class="rounded-[26px] border border-violet-100 bg-violet-50/60 px-5 py-5"
|
||||
>
|
||||
<p class="font-semibold text-slate-800">{{ activity.label }}</p>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ activity.age }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card class="p-7">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Review Queue</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">Freitext-Nominierungen und Alias-Faelle, die das Team direkt in Kandidaten ueberfuehren oder verwerfen kann.</p>
|
||||
</div>
|
||||
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
||||
{{ seasonDetail.pendingNominations.length }} offen
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<div
|
||||
v-for="nomination in seasonDetail.pendingNominations"
|
||||
:key="nomination.id"
|
||||
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">{{ nomination.categoryName }}</p>
|
||||
<h3 class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">{{ nomination.candidateText }}</h3>
|
||||
<p class="mt-2 text-sm text-slate-500">
|
||||
Von {{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
|
||||
ID {{ nomination.id }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-4 md:grid-cols-3">
|
||||
<input
|
||||
v-model="reviewForms[nomination.id].displayName"
|
||||
type="text"
|
||||
class="rounded-2xl border border-violet-200 px-4 py-3"
|
||||
placeholder="Display Name"
|
||||
/>
|
||||
<input
|
||||
v-model="reviewForms[nomination.id].channelSlug"
|
||||
type="text"
|
||||
class="rounded-2xl border border-violet-200 px-4 py-3"
|
||||
placeholder="@channel"
|
||||
/>
|
||||
<input
|
||||
v-model="reviewForms[nomination.id].platform"
|
||||
type="text"
|
||||
class="rounded-2xl border border-violet-200 px-4 py-3"
|
||||
placeholder="Platform"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap justify-end gap-3">
|
||||
<Button :disabled="reviewSaving === nomination.id" variant="secondary" @click="rejectNomination(nomination.id)">
|
||||
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Verwerfen' }}
|
||||
</Button>
|
||||
<Button :disabled="reviewSaving === nomination.id" @click="approveNomination(nomination.id)">
|
||||
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Als Kandidat uebernehmen' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,164 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import Select from 'primevue/select'
|
||||
|
||||
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||
import Button from '../../components/ui/Button.vue'
|
||||
import Card from '../../components/ui/Card.vue'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
|
||||
const store = useAwardsStore()
|
||||
const candidateSaving = ref<number | 'new' | null>(null)
|
||||
const adminMessage = ref('')
|
||||
const adminError = ref('')
|
||||
|
||||
const newCandidateForm = reactive({
|
||||
categoryId: 0,
|
||||
displayName: '',
|
||||
channelSlug: '',
|
||||
platform: 'Twitch',
|
||||
})
|
||||
|
||||
const candidateForms = reactive<Record<number, {
|
||||
categoryId: number
|
||||
displayName: string
|
||||
channelSlug: string
|
||||
platform: string
|
||||
}>>({})
|
||||
|
||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
||||
const categoryOptions = computed(() =>
|
||||
seasonDetail.value.categories.map((category) => ({
|
||||
label: `${category.groupName} · ${category.name}`,
|
||||
value: category.id,
|
||||
})),
|
||||
)
|
||||
|
||||
watch(
|
||||
seasonDetail,
|
||||
(detail) => {
|
||||
for (const candidate of detail.candidates) {
|
||||
candidateForms[candidate.id] = {
|
||||
categoryId: candidate.categoryId,
|
||||
displayName: candidate.displayName,
|
||||
channelSlug: candidate.channelSlug,
|
||||
platform: candidate.platform,
|
||||
}
|
||||
}
|
||||
|
||||
newCandidateForm.categoryId = detail.categories[0]?.id ?? 0
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function saveCandidate(candidateId: number) {
|
||||
if (!selectedSeasonId.value) return
|
||||
|
||||
candidateSaving.value = candidateId
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
await store.updateAdminCandidate(candidateId, selectedSeasonId.value, candidateForms[candidateId])
|
||||
adminMessage.value = 'Kandidat gespeichert.'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Kandidat konnte nicht gespeichert werden.'
|
||||
} finally {
|
||||
candidateSaving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function createCandidate() {
|
||||
if (!selectedSeasonId.value || !newCandidateForm.categoryId) return
|
||||
|
||||
candidateSaving.value = 'new'
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
await store.createAdminCandidate(selectedSeasonId.value, newCandidateForm)
|
||||
adminMessage.value = 'Kandidat angelegt.'
|
||||
newCandidateForm.displayName = ''
|
||||
newCandidateForm.channelSlug = ''
|
||||
newCandidateForm.platform = 'Twitch'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Kandidat konnte nicht angelegt werden.'
|
||||
} finally {
|
||||
candidateSaving.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<AdminSeasonToolbar />
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
||||
<Card class="p-7">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Kandidatenpflege</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">Bekannte Kandidaten koennen pro Kategorie gepflegt und fuer Voting und Archiv genutzt werden.</p>
|
||||
</div>
|
||||
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
||||
{{ seasonDetail.candidates.length }} Kandidaten
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<div
|
||||
v-for="candidate in seasonDetail.candidates"
|
||||
:key="candidate.id"
|
||||
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
|
||||
>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<Select
|
||||
v-model="candidateForms[candidate.id].categoryId"
|
||||
:options="categoryOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="w-full"
|
||||
/>
|
||||
<input v-model="candidateForms[candidate.id].displayName" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Display Name" />
|
||||
<input v-model="candidateForms[candidate.id].channelSlug" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="@channel" />
|
||||
<input v-model="candidateForms[candidate.id].platform" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Platform" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Button :disabled="candidateSaving === candidate.id" @click="saveCandidate(candidate.id)">
|
||||
{{ candidateSaving === candidate.id ? 'Speichert ...' : 'Kandidat speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-7">
|
||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Neuer Kandidat</h2>
|
||||
<div class="mt-6 space-y-4">
|
||||
<Select
|
||||
v-model="newCandidateForm.categoryId"
|
||||
:options="categoryOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="w-full"
|
||||
/>
|
||||
<input v-model="newCandidateForm.displayName" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Display Name" />
|
||||
<input v-model="newCandidateForm.channelSlug" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="@channel" />
|
||||
<input v-model="newCandidateForm.platform" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Platform" />
|
||||
<Button :disabled="candidateSaving === 'new' || !selectedSeasonId || !newCandidateForm.categoryId" @click="createCandidate">
|
||||
{{ candidateSaving === 'new' ? 'Erstellt ...' : 'Kandidat anlegen' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p v-if="adminMessage" class="mt-6 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
|
||||
{{ adminMessage }}
|
||||
</p>
|
||||
<p v-if="adminError" class="mt-6 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||
{{ adminError }}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import Column from 'primevue/column'
|
||||
import DataTable from 'primevue/datatable'
|
||||
|
||||
import Card from '../../components/ui/Card.vue'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
|
||||
const store = useAwardsStore()
|
||||
|
||||
const metrics = computed(() => store.admin.metrics)
|
||||
const activities = computed(() => store.admin.activities)
|
||||
const topCategories = computed(() => store.admin.topCategories)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-5 lg:grid-cols-4">
|
||||
<Card
|
||||
v-for="metric in metrics"
|
||||
:key="metric.label"
|
||||
class="p-7"
|
||||
>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">{{ metric.label }}</p>
|
||||
<strong class="mt-4 block text-4xl text-violet-800">{{ metric.value.toLocaleString('de-DE') }}</strong>
|
||||
<p class="mt-2 text-sm text-slate-500">{{ metric.note }}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
|
||||
<Card class="p-7">
|
||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Top Kategorien nach Votes</h2>
|
||||
<DataTable :value="topCategories" class="mt-6" striped-rows>
|
||||
<Column field="category" header="Kategorie" />
|
||||
<Column field="votes" header="Votes">
|
||||
<template #body="{ data }">
|
||||
{{ Number(data.votes).toLocaleString('de-DE') }}
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</Card>
|
||||
|
||||
<Card class="p-7">
|
||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Letzte Aktivitaeten</h2>
|
||||
<div class="mt-6 space-y-4">
|
||||
<div
|
||||
v-for="activity in activities"
|
||||
:key="activity.label"
|
||||
class="rounded-[26px] border border-violet-100 bg-violet-50/60 px-5 py-5"
|
||||
>
|
||||
<p class="font-semibold text-slate-800">{{ activity.label }}</p>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ activity.age }}</p>
|
||||
</div>
|
||||
<p v-if="activities.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||
Noch keine aktuellen Audit-Aktivitaeten vorhanden.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { RouterLink, RouterView, useRoute } from 'vue-router'
|
||||
|
||||
import Card from '../../components/ui/Card.vue'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useAwardsStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Dashboard', to: '/admin/dashboard', description: 'KPIs, Trends, letzte Aktivitaet' },
|
||||
{ label: 'Seasons', to: '/admin/seasons', description: 'Season-Status, Kategorien, Limits' },
|
||||
{ label: 'Candidates', to: '/admin/candidates', description: 'Kandidatenbasis pro Season pflegen' },
|
||||
{ label: 'Reviews', to: '/admin/reviews', description: 'Freitext-Nominierungen bearbeiten' },
|
||||
{ label: 'Risk & Audit', to: '/admin/risk', description: 'Flags pruefen, Aktionen nachvollziehen' },
|
||||
]
|
||||
|
||||
const currentSeason = computed(() => store.adminSeasonDetail)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!authStore.isAdmin) return
|
||||
await store.initializeAdminWorkspace()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-8 pb-14">
|
||||
<div class="space-y-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Admin</p>
|
||||
<h1 class="max-w-[13ch] font-[Cormorant_Garamond] text-6xl leading-[0.92] text-violet-800">
|
||||
Betriebswerkzeug fuer Awards, Moderation und Risiko-Sichtung
|
||||
</h1>
|
||||
<p class="max-w-3xl text-lg leading-8 text-slate-600">
|
||||
Der Admin-Bereich ist in klar getrennte Arbeitszonen aufgeteilt, damit Season-Pflege, Review und Monitoring nicht mehr auf einer einzigen Seite kollidieren.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<Card class="h-fit p-4">
|
||||
<nav class="space-y-2">
|
||||
<RouterLink
|
||||
v-for="item in navItems"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="block rounded-[24px] border px-4 py-4 transition"
|
||||
:class="route.path === item.to ? 'border-violet-200 bg-violet-100/80 text-violet-900' : 'border-transparent bg-white/70 text-slate-700 hover:border-violet-100 hover:bg-violet-50/70'"
|
||||
>
|
||||
<p class="text-sm font-semibold uppercase tracking-[0.18em]">{{ item.label }}</p>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">{{ item.description }}</p>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<div class="mt-6 rounded-[24px] border border-violet-100 bg-violet-50/60 px-4 py-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Aktive Season</p>
|
||||
<h2 class="mt-3 font-[Cormorant_Garamond] text-3xl text-violet-800">
|
||||
{{ currentSeason.year ? `${currentSeason.year}` : 'Keine Season' }}
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-slate-600">{{ currentSeason.name || 'Bitte Season auswaehlen.' }}</p>
|
||||
<p class="mt-3 text-xs uppercase tracking-[0.2em] text-slate-500">{{ currentSeason.currentPhase || 'Kein Status' }}</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<RouterView />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,150 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||
import Button from '../../components/ui/Button.vue'
|
||||
import Card from '../../components/ui/Card.vue'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
|
||||
const store = useAwardsStore()
|
||||
const reviewSaving = ref<number | null>(null)
|
||||
const adminMessage = ref('')
|
||||
const adminError = ref('')
|
||||
|
||||
const reviewForms = reactive<Record<number, {
|
||||
displayName: string
|
||||
channelSlug: string
|
||||
platform: string
|
||||
}>>({})
|
||||
|
||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
||||
|
||||
watch(
|
||||
seasonDetail,
|
||||
(detail) => {
|
||||
for (const nomination of detail.pendingNominations) {
|
||||
reviewForms[nomination.id] = {
|
||||
displayName: nomination.candidateText,
|
||||
channelSlug: '',
|
||||
platform: 'Twitch',
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function approveNomination(nominationId: number) {
|
||||
if (!selectedSeasonId.value) return
|
||||
|
||||
reviewSaving.value = nominationId
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
await store.approveAdminNomination(nominationId, selectedSeasonId.value, reviewForms[nominationId])
|
||||
adminMessage.value = 'Nominierung wurde in die Kandidatenliste uebernommen.'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht uebernommen werden.'
|
||||
} finally {
|
||||
reviewSaving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectNomination(nominationId: number) {
|
||||
if (!selectedSeasonId.value) return
|
||||
|
||||
reviewSaving.value = nominationId
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
await store.rejectAdminNomination(nominationId, selectedSeasonId.value)
|
||||
adminMessage.value = 'Nominierung wurde aus der Review Queue entfernt.'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht verworfen werden.'
|
||||
} finally {
|
||||
reviewSaving.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<AdminSeasonToolbar />
|
||||
|
||||
<Card class="p-7">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Review Queue</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">Freitext-Nominierungen und Alias-Faelle, die das Team direkt in Kandidaten ueberfuehren oder verwerfen kann.</p>
|
||||
</div>
|
||||
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
||||
{{ seasonDetail.pendingNominations.length }} offen
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="adminMessage" class="mt-6 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
|
||||
{{ adminMessage }}
|
||||
</p>
|
||||
<p v-if="adminError" class="mt-6 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||
{{ adminError }}
|
||||
</p>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<div
|
||||
v-for="nomination in seasonDetail.pendingNominations"
|
||||
:key="nomination.id"
|
||||
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">{{ nomination.categoryName }}</p>
|
||||
<h3 class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">{{ nomination.candidateText }}</h3>
|
||||
<p class="mt-2 text-sm text-slate-500">
|
||||
Von {{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
|
||||
ID {{ nomination.id }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-4 md:grid-cols-3">
|
||||
<input
|
||||
v-model="reviewForms[nomination.id].displayName"
|
||||
type="text"
|
||||
class="rounded-2xl border border-violet-200 px-4 py-3"
|
||||
placeholder="Display Name"
|
||||
/>
|
||||
<input
|
||||
v-model="reviewForms[nomination.id].channelSlug"
|
||||
type="text"
|
||||
class="rounded-2xl border border-violet-200 px-4 py-3"
|
||||
placeholder="@channel"
|
||||
/>
|
||||
<input
|
||||
v-model="reviewForms[nomination.id].platform"
|
||||
type="text"
|
||||
class="rounded-2xl border border-violet-200 px-4 py-3"
|
||||
placeholder="Platform"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap justify-end gap-3">
|
||||
<Button :disabled="reviewSaving === nomination.id" variant="secondary" @click="rejectNomination(nomination.id)">
|
||||
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Verwerfen' }}
|
||||
</Button>
|
||||
<Button :disabled="reviewSaving === nomination.id" @click="approveNomination(nomination.id)">
|
||||
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Als Kandidat uebernehmen' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="seasonDetail.pendingNominations.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||
Keine offenen Review-Faelle in der aktuell gewaehlten Season.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Button from '../../components/ui/Button.vue'
|
||||
import Card from '../../components/ui/Card.vue'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
|
||||
const store = useAwardsStore()
|
||||
const riskSaving = ref<number | null>(null)
|
||||
const adminMessage = ref('')
|
||||
const adminError = ref('')
|
||||
|
||||
const riskFlags = computed(() => store.admin.riskFlags)
|
||||
const auditEntries = computed(() => store.admin.auditEntries)
|
||||
|
||||
async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
|
||||
riskSaving.value = riskFlagId
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
await store.resolveRiskFlag(riskFlagId, status)
|
||||
adminMessage.value = `Risk Flag ${riskFlagId} wurde als ${status} markiert.`
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Risk Flag konnte nicht aktualisiert werden.'
|
||||
} finally {
|
||||
riskSaving.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
||||
<Card class="p-7">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Risk Center</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">Auffaellige Login-, Nominierungs- und Voting-Muster fuer die manuelle Sichtung.</p>
|
||||
</div>
|
||||
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
||||
{{ riskFlags.length }} offen
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="adminMessage" class="mt-6 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
|
||||
{{ adminMessage }}
|
||||
</p>
|
||||
<p v-if="adminError" class="mt-6 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||
{{ adminError }}
|
||||
</p>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<div
|
||||
v-for="flag in riskFlags"
|
||||
:key="flag.id"
|
||||
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">{{ flag.source }} · {{ flag.type }}</p>
|
||||
<h3 class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">{{ flag.summary }}</h3>
|
||||
<p class="mt-2 text-sm text-slate-500">
|
||||
{{ flag.twitchUserId || 'unbekannter User' }} · {{ flag.createdFromIp }} · {{ new Date(flag.createdAt).toLocaleString('de-DE') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm font-semibold uppercase tracking-[0.2em] text-slate-600">
|
||||
{{ flag.severity }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pre class="mt-4 overflow-x-auto rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-xs text-slate-600">{{ flag.metadataJson }}</pre>
|
||||
|
||||
<div class="mt-4 flex flex-wrap justify-end gap-3">
|
||||
<Button :disabled="riskSaving === flag.id" variant="secondary" @click="resolveRiskFlag(flag.id, 'dismissed')">
|
||||
{{ riskSaving === flag.id ? 'Speichert ...' : 'Dismiss' }}
|
||||
</Button>
|
||||
<Button :disabled="riskSaving === flag.id" @click="resolveRiskFlag(flag.id, 'resolved')">
|
||||
{{ riskSaving === flag.id ? 'Speichert ...' : 'Resolve' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="riskFlags.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||
Keine offenen Risk Flags vorhanden.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-7">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Audit Log</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">Nachvollziehbare Admin-Aktionen fuer Kategorie-, Kandidaten- und Review-Aenderungen.</p>
|
||||
</div>
|
||||
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
||||
{{ auditEntries.length }} Eintraege
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<div
|
||||
v-for="entry in auditEntries"
|
||||
:key="entry.id"
|
||||
class="rounded-[26px] border border-violet-100 bg-violet-50/60 px-5 py-5"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="font-semibold text-slate-800">{{ entry.summary }}</p>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
{{ entry.adminTwitchUserId }} · {{ entry.actionType }} · {{ entry.entityType }} {{ entry.entityId }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-sm text-slate-500">{{ new Date(entry.createdAt).toLocaleString('de-DE') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="auditEntries.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||
Noch keine Audit-Eintraege vorhanden.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,229 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||
import Button from '../../components/ui/Button.vue'
|
||||
import Card from '../../components/ui/Card.vue'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
|
||||
const store = useAwardsStore()
|
||||
const seasonSaving = ref(false)
|
||||
const categorySaving = ref<number | 'new' | null>(null)
|
||||
const adminMessage = ref('')
|
||||
const adminError = ref('')
|
||||
|
||||
const seasonForm = reactive({
|
||||
currentPhase: '',
|
||||
isCurrent: false,
|
||||
})
|
||||
|
||||
const newCategoryForm = reactive({
|
||||
groupName: '',
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
sortOrder: 1,
|
||||
maxNomineesPerUser: 3,
|
||||
})
|
||||
|
||||
const editForms = reactive<Record<number, {
|
||||
groupName: string
|
||||
name: string
|
||||
slug: string
|
||||
description: string
|
||||
sortOrder: number
|
||||
maxNomineesPerUser: number
|
||||
}>>({})
|
||||
|
||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
||||
|
||||
watch(
|
||||
seasonDetail,
|
||||
(detail) => {
|
||||
seasonForm.currentPhase = detail.currentPhase
|
||||
seasonForm.isCurrent = detail.isCurrent
|
||||
|
||||
for (const category of detail.categories) {
|
||||
editForms[category.id] = {
|
||||
groupName: category.groupName,
|
||||
name: category.name,
|
||||
slug: category.slug,
|
||||
description: category.description,
|
||||
sortOrder: category.sortOrder,
|
||||
maxNomineesPerUser: category.maxNomineesPerUser,
|
||||
}
|
||||
}
|
||||
|
||||
newCategoryForm.sortOrder = detail.categories.length + 1
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function saveSeason() {
|
||||
if (!selectedSeasonId.value) return
|
||||
|
||||
seasonSaving.value = true
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
await store.updateAdminSeason(selectedSeasonId.value, {
|
||||
currentPhase: seasonForm.currentPhase,
|
||||
isCurrent: seasonForm.isCurrent,
|
||||
})
|
||||
adminMessage.value = 'Season-Einstellungen gespeichert.'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Season konnte nicht gespeichert werden.'
|
||||
} finally {
|
||||
seasonSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCategory(categoryId: number) {
|
||||
if (!selectedSeasonId.value) return
|
||||
|
||||
categorySaving.value = categoryId
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
await store.updateAdminCategory(categoryId, selectedSeasonId.value, editForms[categoryId])
|
||||
adminMessage.value = 'Kategorie gespeichert.'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Kategorie konnte nicht gespeichert werden.'
|
||||
} finally {
|
||||
categorySaving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function createCategory() {
|
||||
if (!selectedSeasonId.value) return
|
||||
|
||||
categorySaving.value = 'new'
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
await store.createAdminCategory(selectedSeasonId.value, newCategoryForm)
|
||||
adminMessage.value = 'Neue Kategorie angelegt.'
|
||||
newCategoryForm.groupName = ''
|
||||
newCategoryForm.name = ''
|
||||
newCategoryForm.slug = ''
|
||||
newCategoryForm.description = ''
|
||||
newCategoryForm.sortOrder = seasonDetail.value.categories.length + 1
|
||||
newCategoryForm.maxNomineesPerUser = 3
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Kategorie konnte nicht angelegt werden.'
|
||||
} finally {
|
||||
categorySaving.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<AdminSeasonToolbar />
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
||||
<Card class="p-7">
|
||||
<div class="flex flex-col gap-6">
|
||||
<div>
|
||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Season Setup</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">Phase, Current-Status und Basiskontext fuer die aktive Awards-Season.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<label class="text-sm font-semibold text-slate-600">Phase</label>
|
||||
<input
|
||||
v-model="seasonForm.currentPhase"
|
||||
type="text"
|
||||
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-3 rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-4 text-sm text-slate-700">
|
||||
<input v-model="seasonForm.isCurrent" type="checkbox" class="h-4 w-4 accent-violet-600" />
|
||||
Diese Season ist die aktuelle Public Season
|
||||
</label>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<Button :disabled="seasonSaving || !selectedSeasonId" @click="saveSeason">
|
||||
{{ seasonSaving ? 'Speichert ...' : 'Season speichern' }}
|
||||
</Button>
|
||||
<span class="text-sm text-slate-500">
|
||||
{{ seasonDetail.year }} · {{ seasonDetail.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="adminMessage" class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
|
||||
{{ adminMessage }}
|
||||
</p>
|
||||
<p v-if="adminError" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||
{{ adminError }}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-7">
|
||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Neue Kategorie</h2>
|
||||
<div class="mt-6 space-y-4">
|
||||
<input v-model="newCategoryForm.groupName" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Group Name" />
|
||||
<input v-model="newCategoryForm.name" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Kategorie-Name" />
|
||||
<input v-model="newCategoryForm.slug" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Slug" />
|
||||
<textarea v-model="newCategoryForm.description" class="min-h-28 w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Beschreibung" />
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<input v-model="newCategoryForm.sortOrder" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Sortierung" />
|
||||
<input v-model="newCategoryForm.maxNomineesPerUser" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Max Nominees" />
|
||||
</div>
|
||||
<Button :disabled="categorySaving === 'new' || !selectedSeasonId" @click="createCategory">
|
||||
{{ categorySaving === 'new' ? 'Erstellt ...' : 'Kategorie anlegen' }}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card class="p-7">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Kategorien der Season</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">Sortierung, Slugs und Limits werden hier pro Jahr gepflegt.</p>
|
||||
</div>
|
||||
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
||||
{{ seasonDetail.categories.length }} Kategorien
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<div
|
||||
v-for="category in seasonDetail.categories"
|
||||
:key="category.id"
|
||||
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
|
||||
>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<input v-model="editForms[category.id].groupName" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Group" />
|
||||
<input v-model="editForms[category.id].name" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Name" />
|
||||
<input v-model="editForms[category.id].slug" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Slug" />
|
||||
<input v-model="editForms[category.id].sortOrder" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Sortierung" />
|
||||
<input v-model="editForms[category.id].maxNomineesPerUser" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Limit" />
|
||||
<div class="flex items-center rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
|
||||
{{ category.candidateCount }} Kandidaten in dieser Kategorie
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-model="editForms[category.id].description"
|
||||
class="mt-4 min-h-28 w-full rounded-2xl border border-violet-200 px-4 py-3"
|
||||
placeholder="Beschreibung"
|
||||
/>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Button :disabled="categorySaving === category.id" @click="saveCategory(category.id)">
|
||||
{{ categorySaving === category.id ? 'Speichert ...' : 'Kategorie speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user