Wish and veckomeny fixes
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Elias Jansson
2025-09-22 21:22:43 +02:00
parent 5a17df917d
commit 9299e29ea6
20 changed files with 5139 additions and 151 deletions

View File

@@ -230,6 +230,11 @@ namespace Aberwyn.Controllers
WeekNumber = resolvedWeek,
Year = resolvedYear,
WeeklyMenus = menus,
WishList = _context.MealWishes
.Include(w => w.RequestedByUser)
.Where(w => !w.IsArchived)
.OrderByDescending(w => w.CreatedAt)
.ToList()
};
var recent = menuService
@@ -501,6 +506,66 @@ namespace Aberwyn.Controllers
return View(new DoughPlan { AntalPizzor = 8, ViktPerPizza = 220 });
}
[HttpGet]
public IActionResult GetRecentMenuEntries(int weeksBack = 4)
{
var today = DateTime.Today;
// Datum för måndag i nuvarande vecka
int deltaToMonday = ((int)today.DayOfWeek + 6) % 7; // måndag=0, söndag=6
var thisWeekMonday = today.AddDays(-deltaToMonday);
// Startdatum: måndag X veckor bak
var startMonday = thisWeekMonday.AddDays(-7 * weeksBack);
// Slutdatum: söndag för förra veckan
var endSunday = thisWeekMonday.AddDays(-1);
// Hämta alla veckomenyer inom intervallet
var allMenus = _context.WeeklyMenus
.Where(w => w.Date >= startMonday && w.Date <= endSunday)
.OrderBy(w => w.Date)
.ToList();
// Hämta alla relevanta meal IDs
var mealIds = allMenus
.SelectMany(w => new[] { w.BreakfastMealId, w.LunchMealId, w.DinnerMealId })
.Where(id => id.HasValue)
.Select(id => id!.Value)
.Distinct()
.ToList();
var meals = _context.Meals
.Where(m => mealIds.Contains(m.Id))
.ToDictionary(m => m.Id, m => m.Name);
// Skapa entries
var entries = allMenus
.Select(w => new RecentMenuEntry
{
Date = w.Date,
WeekNumber = ISOWeek.GetWeekOfYear(w.Date),
Year = w.Date.Year,
BreakfastMealName = w.BreakfastMealId.HasValue && meals.ContainsKey(w.BreakfastMealId.Value)
? meals[w.BreakfastMealId.Value]
: "—",
LunchMealName = w.LunchMealId.HasValue && meals.ContainsKey(w.LunchMealId.Value)
? meals[w.LunchMealId.Value]
: "—",
DinnerMealName = w.DinnerMealId.HasValue && meals.ContainsKey(w.DinnerMealId.Value)
? meals[w.DinnerMealId.Value]
: "—"
})
.OrderBy(e => e.Date)
.ToList();
return Json(entries);
}

View File

@@ -1,7 +1,8 @@
// MealMenuApiController.cs
using Aberwyn.Models;
using Aberwyn.Data;
using Aberwyn.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using System.Net.Http.Headers;
@@ -104,8 +105,12 @@ namespace Aberwyn.Controllers
return StatusCode(500, "Failed to add meal.");
}
#region Skolmat
[HttpGet("skolmat")]
[HttpGet("skolmat")]
public async Task<IActionResult> GetSkolmat(int week, [FromQuery] string sensor = "sensor.engelbrektsskolan")
{
var token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI3M2Q5ODIyYzU4ZWI0MjM4OWEyMGQ2MWQ2MWVhOWYzYyIsImlhdCI6MTc0OTE1MzY1MCwiZXhwIjoyMDY0NTEzNjUwfQ.8C_dKm7P1BbFVJKc_wT76YnQqiZxkP9EzrsLbfD0Ml8";

View File

@@ -0,0 +1,124 @@
using Aberwyn.Data;
using Aberwyn.Models; // Byt till din namespace
using Humanizer;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System;
using System.Security.Claims;
[ApiController]
[Route("api/[controller]")]
public class MealWishController : ControllerBase
{
private readonly ApplicationDbContext _context;
public MealWishController(ApplicationDbContext context)
{
_context = context;
}
// 1. Skapa en ny önskan
[HttpPost("create")]
public async Task<ActionResult<MealWishDto>> Create([FromBody] CreateMealWishDto dto)
{
var wish = new MealWish
{
Name = dto.Name,
Recipe = dto.Recipe
};
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anonymous";
wish.RequestedByUserId = userId;
wish.CreatedAt = DateTime.UtcNow;
wish.IsArchived = false;
wish.IsImported = false;
_context.MealWishes.Add(wish);
await _context.SaveChangesAsync();
return Ok(MealWishDto.FromEntity(wish)); // ✅ returnera DTO
}
// 2. Hämta alla önskningar (admin)
[HttpGet("all")]
public async Task<IActionResult> GetAll(bool includeArchived = false)
{
var wishes = await _context.MealWishes
.Include(w => w.RequestedByUser) // hämta användaren
.Where(w => includeArchived || !w.IsArchived)
.OrderByDescending(w => w.CreatedAt)
.Select(w => new {
w.Id,
w.Name,
w.Recipe,
RequestedByUserName = w.RequestedByUser != null ? w.RequestedByUser.UserName : "Okänd",
CreatedAt = w.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm"),
w.IsArchived,
w.IsImported
})
.ToListAsync();
return Ok(wishes);
}
// 3. Avfärda / arkivera
[HttpPost("{id}/archive")]
public async Task<IActionResult> Archive(int id)
{
var wish = await _context.MealWishes.FindAsync(id);
if (wish == null) return NotFound();
wish.IsArchived = true;
await _context.SaveChangesAsync();
return Ok(wish);
}
// 4. Importera till Meals
[HttpPost("{id}/import")]
public async Task<IActionResult> Import(int id)
{
var wish = await _context.MealWishes.FindAsync(id);
if (wish == null) return NotFound();
if (wish.IsImported)
return BadRequest("Denna rätt har redan importerats.");
// Skapa en ny Meal
var meal = new Meal
{
Name = wish.Name,
Instructions = wish.Recipe,
IsAvailable = true,
CreatedAt = DateTime.UtcNow,
IsPublished = false
};
_context.Meals.Add(meal);
await _context.SaveChangesAsync();
wish.LinkedMealId = meal.Id;
wish.IsImported = true;
wish.IsArchived = true;
await _context.SaveChangesAsync();
return Ok(wish);
}
[HttpGet("search")]
public async Task<IActionResult> Search([FromQuery] string name)
{
if (string.IsNullOrWhiteSpace(name)) return BadRequest();
var meals = await _context.Meals
.Where(m => m.Name.Contains(name))
.OrderBy(m => m.Name)
.Take(10)
.Select(m => new { m.Id, m.Name })
.ToListAsync();
return Ok(meals);
}
}

View File

@@ -37,6 +37,7 @@ namespace Aberwyn.Data
);
builder.Entity<TorrentItem>()
.OwnsOne(t => t.Metadata);
}
@@ -66,6 +67,8 @@ namespace Aberwyn.Data
public DbSet<RssFeed> RssFeeds { get; set; }
public DbSet<DownloadRule> DownloadRules { get; set; }
public DbSet<UserTorrentSeen> UserTorrentSeen { get; set; }
public DbSet<MealWish> MealWishes { get; set; }
}
}

View File

@@ -435,6 +435,8 @@ public List<WeeklyMenu> GetWeeklyMenu(int weekNumber, int year)
}
return results;
}
public List<MealCategory> GetMealCategories()
{
return _context.MealCategories.OrderBy(c => c.DisplayOrder).ToList();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Aberwyn.Migrations
{
public partial class AddMealWish : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "MealWishes",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Name = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Recipe = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
LinkedMealId = table.Column<int>(type: "int", nullable: true),
RequestedByUserId = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsArchived = table.Column<bool>(type: "tinyint(1)", nullable: false),
IsImported = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MealWishes", x => x.Id);
table.ForeignKey(
name: "FK_MealWishes_Meals_LinkedMealId",
column: x => x.LinkedMealId,
principalTable: "Meals",
principalColumn: "Id");
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_MealWishes_LinkedMealId",
table: "MealWishes",
column: "LinkedMealId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "MealWishes");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Aberwyn.Migrations
{
public partial class AddMealWishUserRelation : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "RequestedByUserId",
table: "MealWishes",
type: "varchar(255)",
nullable: false,
oldClrType: typeof(string),
oldType: "longtext")
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_MealWishes_RequestedByUserId",
table: "MealWishes",
column: "RequestedByUserId");
migrationBuilder.AddForeignKey(
name: "FK_MealWishes_AspNetUsers_RequestedByUserId",
table: "MealWishes",
column: "RequestedByUserId",
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_MealWishes_AspNetUsers_RequestedByUserId",
table: "MealWishes");
migrationBuilder.DropIndex(
name: "IX_MealWishes_RequestedByUserId",
table: "MealWishes");
migrationBuilder.AlterColumn<string>(
name: "RequestedByUserId",
table: "MealWishes",
type: "longtext",
nullable: false,
oldClrType: typeof(string),
oldType: "varchar(255)")
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Aberwyn.Migrations
{
public partial class AddDateToWeeklyMenu : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "Date",
table: "WeeklyMenu",
type: "datetime(6)",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Date",
table: "WeeklyMenu");
}
}
}

View File

@@ -484,6 +484,44 @@ namespace Aberwyn.Migrations
b.ToTable("MealRatings");
});
modelBuilder.Entity("Aberwyn.Models.MealWish", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsArchived")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsImported")
.HasColumnType("tinyint(1)");
b.Property<int?>("LinkedMealId")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Recipe")
.HasColumnType("longtext");
b.Property<string>("RequestedByUserId")
.IsRequired()
.HasColumnType("varchar(255)");
b.HasKey("Id");
b.HasIndex("LinkedMealId");
b.HasIndex("RequestedByUserId");
b.ToTable("MealWishes");
});
modelBuilder.Entity("Aberwyn.Models.PizzaOrder", b =>
{
b.Property<int>("Id")
@@ -719,6 +757,9 @@ namespace Aberwyn.Migrations
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("Date")
.HasColumnType("datetime(6)");
b.Property<int>("DayOfWeek")
.HasColumnType("int");
@@ -1097,6 +1138,23 @@ namespace Aberwyn.Migrations
b.Navigation("Meal");
});
modelBuilder.Entity("Aberwyn.Models.MealWish", b =>
{
b.HasOne("Aberwyn.Models.Meal", "LinkedMeal")
.WithMany()
.HasForeignKey("LinkedMealId");
b.HasOne("Aberwyn.Models.ApplicationUser", "RequestedByUser")
.WithMany()
.HasForeignKey("RequestedByUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LinkedMeal");
b.Navigation("RequestedByUser");
});
modelBuilder.Entity("Aberwyn.Models.PushSubscriber", b =>
{
b.HasOne("Aberwyn.Models.PizzaOrder", "PizzaOrder")

View File

@@ -12,6 +12,7 @@ namespace Aberwyn.Models
public List<WeeklyMenu> WeeklyMenus { get; set; } // List of weekly menu entries
public int WeekNumber { get; set; } // Week number for the menu
public int Year { get; set; } // Year for the menu
}
public class WeeklyMenuDto
{
@@ -40,6 +41,7 @@ public class WeeklyMenu
public int? LunchMealId { get; set; }
public int? DinnerMealId { get; set; }
public string? Cook { get; set; }
public DateTime Date { get; set; }
public int WeekNumber { get; set; }
public int Year { get; set; }
public DateTime CreatedAt { get; set; }
@@ -59,6 +61,8 @@ public class WeeklyMenu
public class RecentMenuEntry
{
public DateTime Date { get; set; }
public int WeekNumber { get; set; } // Lägg till vecka
public int Year { get; set; } // Lägg till år
public string BreakfastMealName { get; set; }
public string LunchMealName { get; set; }
public string DinnerMealName { get; set; }
@@ -176,6 +180,51 @@ public class WeeklyMenu
public List<Meal> Meals { get; set; } = new();
}
public class MealWish
{
[Key]
public int Id { get; set; }
[Required]
public string Name { get; set; }
public string? Recipe { get; set; }
public int? LinkedMealId { get; set; }
[ForeignKey("LinkedMealId")]
public Meal? LinkedMeal { get; set; }
[Required]
public string RequestedByUserId { get; set; }
[ForeignKey(nameof(RequestedByUserId))]
public ApplicationUser? RequestedByUser { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public bool IsArchived { get; set; } = false;
public bool IsImported { get; set; } = false;
}
public class CreateMealWishDto
{
public string Name { get; set; }
public string? Recipe { get; set; }
}
public class MealWishDto
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string? Recipe { get; set; }
public bool IsArchived { get; set; }
public bool IsImported { get; set; }
public static MealWishDto FromEntity(MealWish wish)
{
return new MealWishDto
{
Id = wish.Id,
Name = wish.Name,
Recipe = wish.Recipe,
IsArchived = wish.IsArchived,
IsImported = wish.IsImported
};
}
}
public class MealRating
{

View File

@@ -14,6 +14,7 @@ namespace Aberwyn.Models
public List<RecentMenuEntry> RecentEntries { get; set; } = new();
public List<WeeklyMenu> WeeklyMenus { get; set; } = new();
public List<UserModel> AvailableCooks { get; set; } = new();
public List<MealWish> WishList { get; set; } = new();
public List<WeeklyMenuViewModel> PreviousWeeks { get; set; } = new();
public class RecentMenuEntry
@@ -37,6 +38,8 @@ namespace Aberwyn.Models
)
);
}
}

View File

@@ -1,153 +1,278 @@
@using System.Globalization
@model Aberwyn.Models.WeeklyMenuViewModel
@{
ViewData["Title"] = "Veckomeny";
var days = new[] { "Måndag", "Tisdag", "Onsdag", "Torsdag", "Fredag", "Lördag", "Söndag" };
}
<html lang="sv" ng-app="app">
<head>
<meta charset="utf-8" />
<title>Veckomeny</title>
<link rel="stylesheet" href="~/css/Veckomeny.css" asp-append-version="true" />
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.8.2/angular.min.js"></script>
<script>
window.knownMeals = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(
(ViewBag.AvailableMeals as List<Aberwyn.Models.Meal>)?.Select(m => m.Name.Trim()).ToList() ?? new List<string>()));
</script>
</head>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script>
window.knownMeals = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(
(ViewBag.AvailableMeals as List<Aberwyn.Models.Meal>)?.Select(m => m.Name.Trim()).ToList() ?? new List<string>()));
</script>
<body x-data="recentHistory()" x-init="init()">
<div class="weekly-menu-wrapper"
x-data="{
highlightNew(event) {
const input = event.target;
const val = input.value?.trim().toLowerCase();
input.classList.remove('new-entry', 'existing-entry');
<div class="menu-wishlist-wrapper">
if (!val) return;
<!-- Veckomenyn -->
<section class="weekly-editor">
<h1>Veckomeny - Vecka @Model.WeekNumber</h1>
<div class="week-nav">
<a asp-action="Veckomeny" asp-route-week="@(Model.WeekNumber - 1)" asp-route-year="@Model.Year">&larr; Föregående vecka</a>
<span>Vecka @Model.WeekNumber</span>
<a asp-action="Veckomeny" asp-route-week="@(Model.WeekNumber + 1)" asp-route-year="@Model.Year">Nästa vecka &rarr;</a>
</div>
<form method="post" asp-action="SaveVeckomeny">
<input type="hidden" name="week" value="@Model.WeekNumber" />
<input type="hidden" name="year" value="@Model.Year" />
<table>
<thead>
<tr>
<th class="day-cell">Dag</th>
<th>Måltider</th>
<th>Kock</th>
</tr>
</thead>
<tbody>
@for (int i = 0; i < 7; i++)
{
var dinnerEntry = Model.GetMealEntry(i, "Middag");
var lunchEntry = Model.GetMealEntry(i, "Lunch");
var breakfastEntry = Model.GetMealEntry(i, "Frukost");
<tbody x-data="{ showExtra: false }">
<tr>
<td class="day-cell">
<button type="button" x-on:click="showExtra = !showExtra">
<i :class="showExtra ? 'fa-solid fa-chevron-right rotate-chevron open' : 'fa-solid fa-chevron-right rotate-chevron'"></i>
</button>
@days[i]
</td>
<td class="meal-cell">
<div class="meal-entry-group">
<div class="meal-input-group">
<input type="text"
name="Meal[@i][Middag]"
value="@dinnerEntry?.DinnerMealName"
placeholder="Lägg till middag..."
list="meals-list"
class="meal-input"
:tabindex="showExtra ? 0 : -1"
x-on:input="highlightFromCurrentWeek()" />
<button type="button" class="delete-btn" title="Rensa middag"
onclick="this.previousElementSibling.value=''; highlightMeal('');">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</div>
</td>
<td class="cook-cell">
<select name="Cook[@i]" :tabindex="showExtra ? 0 : -1" class="meal-input">
<option value="">Välj kock</option>
@foreach (var user in Model.AvailableCooks)
{
var selected = Model.WeeklyMenus.FirstOrDefault(m => m.DayOfWeek == i + 1)?.Cook == user.Username;
<option value="@user.Username" selected="@selected">@user.Name</option>
}
</select>
</td>
</tr>
<tr class="extra-row" x-show="showExtra" x-transition x-cloak>
<td></td>
<td colspan="2">
<div class="extra-meals">
<div class="meal-input-group">
<label>Frukost:</label>
<input type="text"
name="Meal[@i][Frukost]"
value="@breakfastEntry?.BreakfastMealName"
placeholder="Lägg till frukost..."
list="meals-list"
class="meal-input"
x-on:input="highlightFromCurrentWeek()" />
<button type="button" class="delete-btn" title="Rensa frukost"
onclick="this.previousElementSibling.value=''; highlightMeal('');">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<div class="meal-input-group">
<label>Lunch:</label>
<input type="text"
name="Meal[@i][Lunch]"
value="@lunchEntry?.LunchMealName"
placeholder="Lägg till lunch..."
list="meals-list"
class="meal-input"
x-on:input="highlightFromCurrentWeek()" />
<button type="button" class="delete-btn" title="Rensa lunch"
onclick="this.previousElementSibling.value=''; highlightMeal('');">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</div>
</td>
</tr>
</tbody>
}
</tbody>
</table>
</form>
</section>
<!-- Wishlist -->
<aside class="wishlist" ng-controller="WishListCtrl as vm" ng-init="vm.loadWishes()">
<h2>Önskemåltider</h2>
<div class="wishlist-grid">
<div class="wishlist-card" ng-repeat="wish in vm.WishList track by wish.Id">
<div class="wishlist-header">
<strong>{{wish.Name}}</strong>
<span class="wishlist-meta">
av {{wish.RequestedByUserName}} • {{wish.CreatedAt}}
</span>
</div>
<div class="wishlist-body" ng-if="wish.Recipe">
<p>{{wish.Recipe | limitTo:100}}{{wish.Recipe.length > 100 ? '...' : ''}}</p>
</div>
<div class="wishlist-actions">
<button type="button" ng-click="vm.importWish(wish.Id)" class="btn import">
<i class="fa-solid fa-plus"></i> Importera
</button>
<button type="button" ng-click="vm.archiveWish(wish.Id)" class="btn archive">
<i class="fa-solid fa-box-archive"></i> Arkivera
</button>
</div>
</div>
</div>
<p ng-if="!vm.WishList.length">Inga önskemål just nu.</p>
</aside>
const isKnown = window.knownMeals.some(name => name.toLowerCase() === val);
input.classList.add(isKnown ? 'existing-entry' : 'new-entry');
}
}">
<section class="weekly-editor">
<h1>Veckomeny - Vecka @Model.WeekNumber</h1>
<div class="week-nav">
<a asp-action="Veckomeny" asp-route-week="@(Model.WeekNumber - 1)" asp-route-year="@Model.Year">&larr; Föregående vecka</a>
<span>Vecka @Model.WeekNumber</span>
<a asp-action="Veckomeny" asp-route-week="@(Model.WeekNumber + 1)" asp-route-year="@Model.Year">Nästa vecka &rarr;</a>
</div>
<form method="post" asp-action="SaveVeckomeny">
<input type="hidden" name="week" value="@Model.WeekNumber" />
<input type="hidden" name="year" value="@Model.Year" />
<table>
<thead>
<tr>
<th class="day-cell">Dag</th>
<th>Måltider</th>
<th>Kock</th>
</tr>
</thead>
<tbody>
@for (int i = 0; i < 7; i++) {
var dinnerEntry = Model.GetMealEntry(i, "Middag");
var lunchEntry = Model.GetMealEntry(i, "Lunch");
var breakfastEntry = Model.GetMealEntry(i, "Frukost");
<tbody x-data="{ showExtra: false }">
<tr>
<td class="day-cell">
<button type="button" x-on:click="showExtra = !showExtra">
<i :class="showExtra ? 'fa-solid fa-chevron-right rotate-chevron open' : 'fa-solid fa-chevron-right rotate-chevron'"></i>
</button>
@days[i]
</td>
<td class="meal-cell">
<div class="meal-entry-group">
<div class="meal-input-group">
<input type="text" name="Meal[@i][Middag]" value="@dinnerEntry?.DinnerMealName" placeholder="Lägg till middag..." list="meals-list" class="meal-input" :tabindex="showExtra ? 0 : -1" x-on:input="highlightNew($event)" />
<button type="button" class="delete-btn" title="Rensa middag" onclick="this.previousElementSibling.value='';">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</div>
</td>
<td class="cook-cell">
<select name="Cook[@i]" :tabindex="showExtra ? 0 : -1" class="meal-input">
<option value="">Välj kock</option>
@foreach(var user in Model.AvailableCooks) {
var selected = Model.WeeklyMenus.FirstOrDefault(m => m.DayOfWeek == i+1)?.Cook == user.Username;
<option value="@user.Username" selected="@selected">@user.Name</option>
}
</select>
</td>
</tr>
<tr class="extra-row" x-show="showExtra" x-transition x-cloak>
<td></td>
<td colspan="2">
<div class="extra-meals">
<div class="meal-input-group">
<label>Frukost:</label>
<input type="text" name="Meal[@i][Frukost]" value="@breakfastEntry?.BreakfastMealName" placeholder="Lägg till frukost..." list="meals-list" class="meal-input" x-on:input="highlightNew($event)" />
<button type="button" class="delete-btn" title="Rensa frukost" onclick="this.previousElementSibling.value='';">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<div class="meal-input-group">
<label>Lunch:</label>
<input type="text" name="Meal[@i][Lunch]" value="@lunchEntry?.LunchMealName" placeholder="Lägg till lunch..." list="meals-list" class="meal-input" x-on:input="highlightNew($event)" />
<button type="button" class="delete-btn" title="Rensa lunch" onclick="this.previousElementSibling.value='';">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</div>
</td>
</tr>
</tbody>
}
</tbody>
</table>
<!-- Översikt senaste 4 veckor, full width -->
<aside class="recent-history-fullwidth">
<h2>Översikt senaste 4 veckor (middag)</h2>
<div class="recent-table-wrapper">
<table>
<thead>
<tr>
<th>Vecka</th>
@foreach (var d in days)
{
<th>@d</th>
}
</tr>
</thead>
<tbody>
<template x-for="week in weeks" :key="week.weekNumber">
<tr>
<td x-text="week.weekNumber"></td>
<template x-for="day in week.days">
<td x-text="day.dinner" :class="day.highlight ? 'highlight' : ''"></td>
</template>
</tr>
</template>
</tbody>
</table>
</div>
</aside>
<script>
function recentHistory() {
return {
weeks: [],
init() {
fetch('/FoodMenu/GetRecentMenuEntries?weeksBack=4')
.then(res => res.json())
.then(data => {
const grouped = {};
data.forEach(e => {
const dt = new Date(e.Date);
const weekNo = getISOWeek(dt);
const dayIdx = dt.getDay() === 0 ? 6 : dt.getDay() - 1;
const key = `W${weekNo}`;
if (!grouped[key]) grouped[key] = Array(7).fill().map(() => ({ dinner: '—', highlight: false }));
grouped[key][dayIdx] = { dinner: e.DinnerMealName?.trim() || '—', highlight: false };
});
<div class="add-meal-wrapper">
<button type="submit" class="save-menu-btn">Spara veckomeny</button>
</div>
this.weeks = Object.keys(grouped)
.sort((a,b) => b.localeCompare(a))
.map(weekKey => ({ weekNumber: parseInt(weekKey.replace('W','')), days: grouped[weekKey] }));
<aside class="recent-history">
<h2>Översikt senaste 4 veckor</h2>
@{
var lastWeeks = Enumerable.Range(1, 4)
.Select(i => DateTime.Now.AddDays(-7 * i))
.Select(dt => new { Year = dt.Year, Week = ISOWeek.GetWeekOfYear(dt) })
.Distinct().ToList();
}
<table>
<thead>
<tr>
<th>Vecka</th>
@foreach (var d in days) {
<th>@d</th>
}
</tr>
</thead>
<tbody>
@foreach (var w in lastWeeks) {
<tr>
<td>@w.Week</td>
@for (int idx = 1; idx <= 7; idx++) {
var entry = Model.RecentEntries?.FirstOrDefault(e =>
e.Date.Year == w.Year &&
ISOWeek.GetWeekOfYear(e.Date) == w.Week &&
((int)e.Date.DayOfWeek == (idx % 7)));
<td>@(entry?.DinnerMealName ?? "—")</td>
this.highlightFromCurrentWeek();
});
function getISOWeek(date) {
const tmpDate = new Date(date.getTime());
tmpDate.setHours(0,0,0,0);
tmpDate.setDate(tmpDate.getDate() + 4 - (tmpDate.getDay()||7));
const yearStart = new Date(tmpDate.getFullYear(),0,1);
return Math.ceil((((tmpDate - yearStart) / 86400000) + 1)/7);
}
</tr>
},
highlightMeal(inputValue) {
const val = inputValue?.trim().toLowerCase();
this.weeks.forEach(week => {
week.days.forEach(day => {
day.highlight = val && day.dinner?.toLowerCase() === val;
});
});
},
highlightFromCurrentWeek() {
// hämta alla inputs som har något värde
const values = Array.from(document.querySelectorAll(".meal-input"))
.map(i => i.value?.trim().toLowerCase())
.filter(v => v);
this.weeks.forEach(week => {
week.days.forEach(day => {
day.highlight = values.includes(day.dinner?.toLowerCase());
});
});
}
</tbody>
</table>
</aside>
<datalist id="meals-list">
@foreach (var meal in (List<Meal>)ViewBag.AvailableMeals)
{
<option value="@meal.Name">@meal.Name</option>
}
</datalist>
</form>
</section>
</div>
}
angular.module('app', [])
.controller('WishListCtrl', ['$http', function($http){
var vm = this;
vm.WishList = [];
vm.loadWishes = function(){
$http.get('/api/MealWish/all')
.then(function(res){
vm.WishList = res.data;
}, function(err){
console.error(err);
});
};
vm.archiveWish = function(id){
$http.post('/api/MealWish/' + id + '/archive')
.then(function(){
vm.WishList = vm.WishList.filter(function(w){ return w.Id !== id; });
}, function(err){
console.error(err);
});
};
vm.importWish = function(id){
$http.post('/api/MealWish/' + id + '/import')
.then(function(){
vm.WishList = vm.WishList.filter(function(w){ return w.Id !== id; });
}, function(err){
console.error(err);
});
};
}]);
</script>
</body>
</html>

View File

@@ -65,7 +65,7 @@
</div>
</div>
<!--
<div class="school-meals card-view">
<h2>Skolmat</h2>
@@ -86,7 +86,33 @@
</div>
</div>
</div>
-->
<!-- Floating button för att öppna popup -->
<button class="floating-btn" ng-click="toggleWishFlyout()" ng-if="isLoggedIn">+</button>
<!-- Popup för önskad rätt -->
<div class="wish-flyout" ng-show="showWishFlyout">
<form ng-submit="submitWish()">
<h3>Önska mat</h3>
<label for="wishName">Namn</label>
<input type="text"
ng-model="newWishName"
placeholder="Skriv eller sök rätt"
ng-change="checkExistingMeal()"
autocomplete="off">
<!-- Autocomplete-lista -->
<ul class="autocomplete-list" ng-if="existingMeals.length > 0">
<li ng-repeat="meal in existingMeals" ng-click="selectExistingMeal(meal)">
{{ meal.Name }}
</li>
</ul>
<!-- Receptfält visas bara om ingen befintlig rätt hittas -->
<textarea ng-if="showRecipeField" ng-model="newWishRecipe" placeholder="Skriv recept"></textarea>
<button type="submit">Skicka önskan</button>
</form>
</div>
</div>
</body>

View File

@@ -51,12 +51,11 @@
@if (User.IsInRole("Admin"))
{
<li>
<a asp-controller="torrent" asp-action="Index"> Torrents
@if (ViewBag.NewTorrentCount > 0)
{
<span class="new-badge">@ViewBag.NewTorrentCount</span>
}
<a asp-controller="torrent" asp-action="Index"> T
@if (ViewBag.NewTorrentCount > 0)
{
<span class="new-badge">@ViewBag.NewTorrentCount</span>
}
</a>
</li>

View File

@@ -184,3 +184,144 @@
padding: 8px 12px;
vertical-align: top;
}
.highlight {
background: linear-gradient(270deg, #ff6ec4, #7873f5, #4ade80, #facc15, #f87171);
background-size: 1000% 1000%;
animation: rainbow 6s linear infinite;
border-radius: 4px;
color: white;
padding: 2px 4px;
}
@keyframes rainbow {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.menu-wishlist-wrapper {
display: flex;
gap: 2rem;
align-items: flex-start;
width: 100%;
}
/* Veckomenyn tar 2/3 av bredden */
.weekly-editor {
flex: 2;
min-width: 400px;
}
.wishlist {
max-width: 100%;
position: sticky;
top: 1rem;
}
.wishlist-grid {
display: grid;
gap: 1rem;
}
.wishlist-card {
background: #fff;
border-radius: 12px;
padding: 1rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.wishlist-card:hover {
transform: translateY(-3px);
box-shadow: 0 6px 16px rgba(0,0,0,0.15);
}
.wishlist-header {
font-size: 1.1rem;
margin-bottom: 0.25rem;
}
.wishlist-meta {
display: block;
font-size: 0.85rem;
color: #666;
}
.wishlist-body {
margin: 0.5rem 0;
font-size: 0.9rem;
color: #444;
}
.wishlist-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.wishlist-actions .btn {
border: none;
padding: 0.4rem 0.8rem;
border-radius: 8px;
cursor: pointer;
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 0.3rem;
}
.wishlist-actions .btn.import {
background: #4ade80;
color: white;
}
.wishlist-actions .btn.import:hover {
background: #22c55e;
}
.wishlist-actions .btn.archive {
background: #f87171;
color: white;
}
.wishlist-actions .btn.archive:hover {
background: #dc2626;
}
/* Wishlist + Recent history till höger */
.wishlist-wrapper {
flex: 1;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.recent-history {
background: var(--header);
border-radius: 8px;
color: var(--text);
padding: 16px;
}
/* Wrap för horisontell scroll */
.recent-table-wrapper {
overflow-x: auto;
}
.recent-history-fullwidth table {
width: 100%;
min-width: 600px; /* säkerställer scroll om skärm <600px */
border-collapse: collapse;
}

View File

@@ -322,4 +322,116 @@ h1 {
.school-meal-title .chevron.rotated {
transform: rotate(0deg);
}
/* Flyout popup */
.wish-flyout {
position: fixed;
bottom: 80px; /* ovanför plustecknet */
right: 20px;
width: 300px;
max-width: 90%;
background-color: var(--card-bg);
border-radius: var(--border-radius);
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
padding: 12px;
z-index: 1001;
display: flex;
flex-direction: column;
}
/* Responsiv på mobil */
@media (max-width: 480px) {
.wish-flyout {
right: 10px;
width: calc(100% - 20px);
bottom: 70px;
}
}
.wish-flyout label {
font-weight: bold;
margin-top: 8px;
}
.wish-flyout input,
.wish-flyout textarea {
width: 100%;
margin-top: 4px;
padding: 6px;
border-radius: var(--border-radius);
border: 1px solid #ccc;
font-size: 0.9rem;
box-sizing: border-box;
}
.wish-flyout textarea {
resize: vertical;
min-height: 60px;
}
.flyout-buttons {
display: flex;
justify-content: flex-end;
gap: 6px;
margin-top: 10px;
}
.flyout-buttons button {
padding: 6px 12px;
border-radius: var(--border-radius);
border: none;
cursor: pointer;
}
.flyout-buttons button[type="submit"] {
background-color: var(--accent);
color: #fff;
}
.flyout-buttons button[type="button"] {
background-color: #ccc;
color: var(--text);
}
/* Floating button fixed i nedre högra hörnet */
.floating-btn {
position: fixed; /* fixed istället för relative/absolute */
bottom: 20px; /* avstånd från botten */
right: 20px; /* avstånd från höger */
width: 50px;
height: 50px;
border-radius: 50%;
background-color: var(--accent);
color: #fff;
font-size: 28px;
border: none;
cursor: pointer;
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
z-index: 1000; /* högre än andra element så den ligger ovanpå */
}
.floating-btn:hover {
background-color: #2B6CB0;
}
.autocomplete-list {
list-style: none;
padding: 0;
margin: 4px 0 0 0;
border: 1px solid #ccc;
border-radius: 4px;
max-height: 150px;
overflow-y: auto;
background: var(--card-bg);
position: absolute;
width: 90%; /* Anpassa efter input */
z-index: 1000;
}
.autocomplete-list li {
padding: 6px 10px;
cursor: pointer;
}
.autocomplete-list li:hover {
background-color: var(--accent);
color: #fff;
}

View File

@@ -17,7 +17,7 @@ angular.module('mealMenuApp', ['ngSanitize'])
$scope.daysOfWeek = ["Måndag", "Tisdag", "Onsdag", "Torsdag", "Fredag", "Lördag", "Söndag"];
$scope.loadMenu = function () {
console.log("Hämtar meny för vecka:", $scope.selectedWeek, $scope.selectedYear);
//console.log("Hämtar meny för vecka:", $scope.selectedWeek, $scope.selectedYear);
$http.get('/api/mealMenuApi/getWeeklyMenu', {
params: { weekNumber: $scope.selectedWeek, year: $scope.selectedYear }
@@ -28,8 +28,8 @@ angular.module('mealMenuApp', ['ngSanitize'])
rawMenu.forEach(item => {
const dayIndex = item.DayOfWeek - 1;
const day = $scope.daysOfWeek[dayIndex];
console.warn("Item:", item);
console.log("day: " + day + "(" + dayIndex + " ) item: " + item.DinnerMealName);
//console.warn("Item:", item);
//console.log("day: " + day + "(" + dayIndex + " ) item: " + item.DinnerMealName);
const thumb = item.DinnerMealThumbnail;
$scope.menu[day] = {
@@ -39,12 +39,12 @@ angular.module('mealMenuApp', ['ngSanitize'])
lunchMealName: item.LunchMealName,
dinnerMealId: item.DinnerMealId,
dinnerMealName: item.DinnerMealName,
imageUrl: thumb ? `data:image/webp;base64,${thumb}` : '/img/default-thumbnail.webp'
imageUrl: thumb ? `data:image/webp;base64,${thumb}` : ''
};
});
}).catch(err => console.error("Fel vid hämtning av meny:", err));
//$scope.loadSchoolMeals(); // Lägg till här
$scope.loadSchoolMeals();
};
$scope.schoolMeals = [];
@@ -104,7 +104,7 @@ angular.module('mealMenuApp', ['ngSanitize'])
};
$scope.getDayImage = function (day) {
return $scope.menu[day]?.imageUrl || '/img/default-thumbnail.webp';
return $scope.menu[day]?.imageUrl || '';
};
$scope.getMealIdByDay = function (day) {
@@ -152,6 +152,76 @@ angular.module('mealMenuApp', ['ngSanitize'])
if (viewBtn) viewBtn.textContent = $scope.getViewIcon();
}, 0);
};
$scope.isLoggedIn = true; // sätt till true/false beroende på användarstatus
$scope.showWishForm = false;
$scope.showRecipeField = true; // kan styras av autocomplete
$scope.newWishName = "";
$scope.newWishRecipe = "";
$scope.showWishFlyout = false;
$scope.toggleWishFlyout = function () {
$scope.showWishFlyout = !$scope.showWishFlyout;
}
$scope.closeWishFlyout = function () {
$scope.showWishFlyout = false;
$scope.newWishName = "";
$scope.newWishRecipe = "";
$scope.showRecipeField = true;
}
$scope.existingMeals = [];
$scope.selectedMeal = null;
$scope.checkExistingMeal = function () {
if (!$scope.newWishName || $scope.newWishName.length < 2) {
$scope.existingMeals = [];
$scope.showRecipeField = true;
return;
}
fetch('/api/MealWish/search?name=' + encodeURIComponent($scope.newWishName))
.then(res => res.json())
.then(data => {
$scope.existingMeals = data;
$scope.$apply(); // viktigt annars uppdateras inte ng-repeat
})
.catch(err => console.error(err));
}
$scope.selectExistingMeal = function (meal) {
$scope.newWishName = meal.Name;
$scope.selectedMeal = meal;
$scope.showRecipeField = true;
$scope.existingMeals = [];
}
$scope.submitWish = function () {
if (!$scope.newWishName) return;
if ($scope.existingMeal) {
wish.LinkedMealId = $scope.existingMeal.Id;
wish.Recipe = $scope.Recipe;
}
const payload = {
Name: $scope.newWishName,
Recipe: $scope.newWishRecipe
};
fetch('/api/MealWish/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
.then(res => res.json())
.then(data => {
console.log("Önskan skickad:", data);
$scope.closeWishFlyout();
$scope.$apply();
})
.catch(err => console.error(err));
}
console.log("Initierar meny och måltidsladdning...");
$scope.loadMenu();