Initial commit: Lagerbestand Blazor Projekt

This commit is contained in:
spaethf committed 2026-09-21 21:28:23 +02:00
commit cba9414e92
105 files changed
+36431

No files matched your search

+52
View File
@@ -0,0 +1,52 @@
using System.ComponentModel.DataAnnotations;
namespace Lagerbestand.Models;
public enum UserRole
{
User = 0,
Worker = 1, // Changed from Owner - limited access to tire operations and inventory
Admin = 2 // Full access to everything
}
public class AppUser
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[MaxLength(100)]
public string Username { get; set; } = string.Empty;
[Required]
[MaxLength(256)]
public string PasswordHash { get; set; } = string.Empty;
[MaxLength(200)]
public string? DisplayName { get; set; }
public UserRole Role { get; set; } = UserRole.User;
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? LastLoginAt { get; set; }
/// <summary>
/// Whether 2FA is enabled for this user (user opted in or admin enforced).
/// </summary>
public bool TwoFactorEnabled { get; set; }
/// <summary>
/// Base32-encoded TOTP secret key for authenticator apps.
/// </summary>
[MaxLength(256)]
public string? TwoFactorSecret { get; set; }
/// <summary>
/// Whether an admin has enforced 2FA for this user.
/// When true, the user cannot disable 2FA themselves.
/// </summary>
public bool TwoFactorEnforced { get; set; }
}
+88
View File
@@ -0,0 +1,88 @@
namespace Lagerbestand.Models;
/// <summary>
/// Represents the storage area type for tire storage locations.
/// The value corresponds to position A in the location code (ABCD format).
/// </summary>
public enum AreaType
{
/// <summary>
/// Werkstatt (Gebäude 1, 3 Regalreihen) - Code prefix: 1
/// </summary>
Werkstatt = 1,
/// <summary>
/// Lager (Gebäude 2, 5 Regalreihen) - Code prefix: 2
/// </summary>
Lager = 2
}
/// <summary>
/// Extension methods for AreaType enum.
/// </summary>
public static class AreaTypeExtensions
{
/// <summary>
/// Gets the German display name for the area type.
/// </summary>
public static string GetDisplayName(this AreaType areaType) => areaType switch
{
AreaType.Lager => "Lager",
AreaType.Werkstatt => "Werkstatt",
_ => areaType.ToString()
};
/// <summary>
/// Gets the single-digit code for the area type.
/// </summary>
public static string GetCode(this AreaType areaType) => ((int)areaType).ToString();
/// <summary>
/// Gets the maximum shelf row number for the building.
/// Werkstatt (Gebäude 1) has 3 rows, Lager (Gebäude 2) has 5 rows.
/// </summary>
public static int GetMaxRow(this AreaType areaType) => areaType switch
{
AreaType.Werkstatt => 3,
AreaType.Lager => 5,
_ => 1
};
/// <summary>
/// Gets a display label including the building number (e.g., "Gebäude 1 – Werkstatt").
/// </summary>
public static string GetBuildingLabel(this AreaType areaType) =>
$"Gebäude {(int)areaType} – {areaType.GetDisplayName()}";
/// <summary>
/// Parses a code digit to AreaType.
/// </summary>
/// <param name="code">Single digit code (1 or 2)</param>
/// <returns>The corresponding AreaType</returns>
/// <exception cref="ArgumentException">If code is not valid</exception>
public static AreaType FromCode(char code) => code switch
{
'1' => AreaType.Werkstatt,
'2' => AreaType.Lager,
_ => throw new ArgumentException($"Ungültiger Bereichscode: {code}. Erlaubt: 1 (Werkstatt), 2 (Lager)", nameof(code))
};
/// <summary>
/// Tries to parse a code digit to AreaType.
/// </summary>
public static bool TryFromCode(char code, out AreaType areaType)
{
areaType = default;
if (code == '1')
{
areaType = AreaType.Werkstatt;
return true;
}
if (code == '2')
{
areaType = AreaType.Lager;
return true;
}
return false;
}
}
+71
View File
@@ -0,0 +1,71 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Lagerbestand.Models;
/// <summary>
/// Type of catalog item
/// </summary>
public enum CatalogItemType
{
/// <summary>
/// Tire category
/// </summary>
Reifen = 0,
/// <summary>
/// Rim/wheel category
/// </summary>
Felgen = 1
}
public class CatalogItem
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[MaxLength(200)]
public string Name { get; set; } = string.Empty;
[MaxLength(500)]
public string? PictureFileName { get; set; }
/// <summary>
/// Size info - tire size for Reifen (e.g., "225/45 R17"), rim size for Felgen (e.g., "17 Zoll")
/// </summary>
[MaxLength(50)]
public string TireSize { get; set; } = string.Empty;
public TireSeason Season { get; set; } = TireSeason.Sommer;
/// <summary>
/// Category type (Reifen or Felgen)
/// </summary>
public CatalogItemType Type { get; set; } = CatalogItemType.Reifen;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
/// <summary>
/// Display label for the size field based on type
/// </summary>
[NotMapped]
public string SizeLabel => Type == CatalogItemType.Felgen ? "Felgengröße" : "Reifengröße";
/// <summary>
/// Display details string
/// </summary>
[NotMapped]
public string DisplayDetails => Type == CatalogItemType.Felgen
? TireSize
: $"{TireSize} | {Season}";
}
public enum TireSeason
{
Sommer = 0,
Winter = 1,
Allrad = 2
}
+86
View File
@@ -0,0 +1,86 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Lagerbestand.Models;
/// <summary>
/// Represents a customer who stores tires at the shop.
/// A customer can have multiple vehicles.
/// </summary>
public class Customer
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(100)]
public string? FirstName { get; set; }
[Required]
[MaxLength(100)]
public string LastName { get; set; } = string.Empty;
[MaxLength(200)]
public string? Company { get; set; }
[MaxLength(20)]
public string? Phone { get; set; }
[MaxLength(100)]
public string? Email { get; set; }
[MaxLength(500)]
public string? Notes { get; set; }
/// <summary>
/// Customer number for easy identification (e.g., K-0001)
/// </summary>
[MaxLength(20)]
public string CustomerNumber { get; set; } = string.Empty;
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
/// <summary>
/// Navigation property for customer's vehicles
/// </summary>
public ICollection<Vehicle> Vehicles { get; set; } = new List<Vehicle>();
/// <summary>
/// Navigation property for customer's stored tire stacks
/// </summary>
public ICollection<TireStack> TireStacks { get; set; } = new List<TireStack>();
/// <summary>
/// Full name of the customer
/// </summary>
[NotMapped]
public string FullName => $"{FirstName} {LastName}".Trim();
/// <summary>
/// Display name including company if available
/// </summary>
[NotMapped]
public string DisplayName => string.IsNullOrEmpty(Company)
? FullName
: $"{FullName} ({Company})";
/// <summary>
/// Gets the primary vehicle or the first vehicle if no primary is set
/// </summary>
[NotMapped]
public Vehicle? PrimaryVehicle => Vehicles
.Where(v => v.IsActive)
.OrderByDescending(v => v.IsPrimary)
.ThenBy(v => v.CreatedAt)
.FirstOrDefault();
/// <summary>
/// Gets all active vehicles for this customer
/// </summary>
[NotMapped]
public IEnumerable<Vehicle> ActiveVehicles => Vehicles.Where(v => v.IsActive);
}
@@ -0,0 +1,48 @@
namespace Lagerbestand.Models;
/// <summary>
/// File storage settings for NFS/disk-based file storage
/// </summary>
public class FileStorageSettings
{
/// <summary>
/// Base path for file storage (e.g., /mnt/nfs/attachments or C:\Storage\Attachments)
/// </summary>
public string BasePath { get; set; } = string.Empty;
/// <summary>
/// Whether file storage is configured and enabled
/// </summary>
public bool IsEnabled { get; set; }
/// <summary>
/// Maximum file size in bytes (default: 10 MB)
/// </summary>
public long MaxFileSizeBytes { get; set; } = 10 * 1024 * 1024;
/// <summary>
/// Validates if the storage path is accessible
/// </summary>
public bool IsPathAccessible()
{
if (string.IsNullOrWhiteSpace(BasePath))
return false;
try
{
return Directory.Exists(BasePath);
}
catch
{
return false;
}
}
/// <summary>
/// Gets the full path for a given relative file path
/// </summary>
public string GetFullPath(string relativePath)
{
return Path.Combine(BasePath, relativePath);
}
}
+29
View File
@@ -0,0 +1,29 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Lagerbestand.Models;
public class InventoryItem
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[MaxLength(500)]
public string QrCode { get; set; } = string.Empty;
[MaxLength(200)]
public string? Name { get; set; }
[MaxLength(500)]
public string? Description { get; set; }
public Guid? CatalogItemId { get; set; }
[ForeignKey(nameof(CatalogItemId))]
public CatalogItem? CatalogItem { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
}
+53
View File
@@ -0,0 +1,53 @@
using System.ComponentModel.DataAnnotations;
namespace Lagerbestand.Models;
/// <summary>
/// Represents a column in the Kanban board (e.g., Backlog, ToDo, In Progress)
/// </summary>
public class KanbanColumn
{
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[MaxLength(100)]
public string Name { get; set; } = string.Empty;
/// <summary>
/// Display order of the column (0-based)
/// </summary>
public int SortOrder { get; set; }
/// <summary>
/// Optional color for the column header
/// </summary>
[MaxLength(7)]
public string? Color { get; set; }
/// <summary>
/// Optional icon/emoji for the column
/// </summary>
[MaxLength(10)]
public string? Icon { get; set; }
/// <summary>
/// Whether this is the default column for new tasks
/// </summary>
public bool IsDefault { get; set; }
/// <summary>
/// Whether this column represents completed tasks (for archiving logic)
/// </summary>
public bool IsCompletedColumn { get; set; }
/// <summary>
/// Whether this column is active
/// </summary>
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
// Navigation properties
public virtual ICollection<KanbanTask> Tasks { get; set; } = new List<KanbanTask>();
}
+61
View File
@@ -0,0 +1,61 @@
using System.ComponentModel.DataAnnotations;
namespace Lagerbestand.Models;
/// <summary>
/// Represents a label that can be attached to Kanban tasks
/// </summary>
public class KanbanLabel
{
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[MaxLength(50)]
public string Name { get; set; } = string.Empty;
/// <summary>
/// Hex color code for the label (e.g., "#FF5733")
/// </summary>
[Required]
[MaxLength(7)]
public string Color { get; set; } = "#6B7280";
/// <summary>
/// Optional description for the label
/// </summary>
[MaxLength(200)]
public string? Description { get; set; }
/// <summary>
/// Display order for labels
/// </summary>
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation properties
public virtual ICollection<KanbanTaskLabel> TaskLabels { get; set; } = new List<KanbanTaskLabel>();
public virtual ICollection<KanbanTaskTemplateLabel> TemplateLabels { get; set; } = new List<KanbanTaskTemplateLabel>();
}
/// <summary>
/// Predefined label colors for easy selection
/// </summary>
public static class KanbanLabelColors
{
public static readonly Dictionary<string, string> Presets = new()
{
{ "Rot", "#EF4444" },
{ "Orange", "#F97316" },
{ "Gelb", "#EAB308" },
{ "Grün", "#22C55E" },
{ "Türkis", "#14B8A6" },
{ "Blau", "#3B82F6" },
{ "Indigo", "#6366F1" },
{ "Violett", "#8B5CF6" },
{ "Pink", "#EC4899" },
{ "Grau", "#6B7280" }
};
}
+32
View File
@@ -0,0 +1,32 @@
namespace Lagerbestand.Models;
/// <summary>
/// Statistics for the Kanban dashboard
/// </summary>
public class KanbanStatistics
{
/// <summary>
/// Total number of non-archived tasks
/// </summary>
public int TotalTasks { get; set; }
/// <summary>
/// Number of tasks due today
/// </summary>
public int TasksDueToday { get; set; }
/// <summary>
/// Number of overdue tasks
/// </summary>
public int OverdueTasks { get; set; }
/// <summary>
/// Number of tasks completed this week
/// </summary>
public int CompletedThisWeek { get; set; }
/// <summary>
/// Count of tasks per column
/// </summary>
public Dictionary<Guid, int> TasksPerColumn { get; set; } = new();
}
+141
View File
@@ -0,0 +1,141 @@
using System.ComponentModel.DataAnnotations;
namespace Lagerbestand.Models;
/// <summary>
/// Priority levels for Kanban tasks
/// </summary>
public enum KanbanTaskPriority
{
Low = 0,
Medium = 1,
High = 2,
Urgent = 3
}
/// <summary>
/// Represents a task/card in the Kanban board
/// </summary>
public class KanbanTask
{
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[MaxLength(200)]
public string Title { get; set; } = string.Empty;
/// <summary>
/// Rich text description of the task
/// </summary>
public string? Description { get; set; }
/// <summary>
/// The column this task belongs to
/// </summary>
public Guid ColumnId { get; set; }
/// <summary>
/// Sort order within the column
/// </summary>
public int SortOrder { get; set; }
/// <summary>
/// Due date for the task
/// </summary>
public DateTime? DueDate { get; set; }
/// <summary>
/// Priority level
/// </summary>
public KanbanTaskPriority Priority { get; set; } = KanbanTaskPriority.Medium;
/// <summary>
/// Estimated time in minutes
/// </summary>
public int? EstimatedMinutes { get; set; }
/// <summary>
/// User who created the task
/// </summary>
public Guid? CreatedByUserId { get; set; }
/// <summary>
/// Whether the task is archived
/// </summary>
public bool IsArchived { get; set; }
/// <summary>
/// When the task was moved to a completed column
/// </summary>
public DateTime? CompletedAt { get; set; }
/// <summary>
/// When the task was archived
/// </summary>
public DateTime? ArchivedAt { get; set; }
/// <summary>
/// Simple notes/memo field for the task
/// </summary>
public string? NotesText { get; set; }
/// <summary>
/// Free text field for tire size notes (Reifengröße)
/// </summary>
public string? TireSizeNote { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
// Navigation properties
public virtual KanbanColumn? Column { get; set; }
public virtual AppUser? CreatedByUser { get; set; }
public virtual ICollection<KanbanTaskTodo> Todos { get; set; } = new List<KanbanTaskTodo>();
public virtual ICollection<KanbanTaskLabel> TaskLabels { get; set; } = new List<KanbanTaskLabel>();
public virtual ICollection<KanbanTaskComment> Comments { get; set; } = new List<KanbanTaskComment>();
public virtual ICollection<KanbanTaskNote> Notes { get; set; } = new List<KanbanTaskNote>();
public virtual ICollection<KanbanTaskAttachment> Attachments { get; set; } = new List<KanbanTaskAttachment>();
public virtual ICollection<KanbanTaskAssignee> Assignees { get; set; } = new List<KanbanTaskAssignee>();
public virtual ICollection<KanbanTaskCustomer> TaskCustomers { get; set; } = new List<KanbanTaskCustomer>();
public virtual ICollection<KanbanTaskTireStack> TaskTireStacks { get; set; } = new List<KanbanTaskTireStack>();
// Computed properties
public bool IsDueToday => DueDate.HasValue && DueDate.Value.Date == DateTime.UtcNow.Date && !CompletedAt.HasValue;
public bool IsOverdue => DueDate.HasValue && DueDate.Value.Date < DateTime.UtcNow.Date && !IsArchived && !CompletedAt.HasValue;
public int TodosCompleted => Todos.Count(t => t.IsCompleted);
public int TodosTotal => Todos.Count;
public string TodoProgress => TodosTotal > 0 ? $"{TodosCompleted}/{TodosTotal}" : "";
public string PriorityText => Priority switch
{
KanbanTaskPriority.Low => "Niedrig",
KanbanTaskPriority.Medium => "Mittel",
KanbanTaskPriority.High => "Hoch",
KanbanTaskPriority.Urgent => "Dringend",
_ => "Mittel"
};
public string PriorityIcon => Priority switch
{
KanbanTaskPriority.Low => "⬇️",
KanbanTaskPriority.Medium => "➡️",
KanbanTaskPriority.High => "⬆️",
KanbanTaskPriority.Urgent => "🔥",
_ => "➡️"
};
public string EstimatedTimeDisplay
{
get
{
if (!EstimatedMinutes.HasValue) return "";
var hours = EstimatedMinutes.Value / 60;
var mins = EstimatedMinutes.Value % 60;
if (hours > 0 && mins > 0) return $"{hours}h {mins}m";
if (hours > 0) return $"{hours}h";
return $"{mins}m";
}
}
}
@@ -0,0 +1,96 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Lagerbestand.Models;
/// <summary>
/// Represents a file attachment on a Kanban task
/// </summary>
public class KanbanTaskAttachment
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid TaskId { get; set; }
[Required]
[MaxLength(255)]
public string FileName { get; set; } = string.Empty;
[Required]
[MaxLength(100)]
public string ContentType { get; set; } = "application/octet-stream";
/// <summary>
/// File size in bytes
/// </summary>
public long FileSize { get; set; }
/// <summary>
/// Relative path to the file in the storage system (NFS/disk)
/// </summary>
[MaxLength(500)]
public string? FilePath { get; set; }
/// <summary>
/// Legacy: File content stored as binary (being migrated to file-based storage)
/// </summary>
[Obsolete("Use FilePath instead. This property is retained for migration purposes only.")]
public byte[]? FileContent { get; set; }
/// <summary>
/// User who uploaded the file
/// </summary>
public Guid? UploadedByUserId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation properties
public virtual KanbanTask? Task { get; set; }
public virtual AppUser? UploadedByUser { get; set; }
// Computed properties
[NotMapped]
public string FileSizeDisplay
{
get
{
if (FileSize < 1024) return $"{FileSize} B";
if (FileSize < 1024 * 1024) return $"{FileSize / 1024.0:F1} KB";
return $"{FileSize / (1024.0 * 1024.0):F1} MB";
}
}
[NotMapped]
public string FileIcon
{
get
{
var ext = Path.GetExtension(FileName).ToLowerInvariant();
return ext switch
{
".pdf" => "📄",
".doc" or ".docx" => "📝",
".xls" or ".xlsx" => "📊",
".ppt" or ".pptx" => "📽️",
".jpg" or ".jpeg" or ".png" or ".gif" or ".webp" => "🖼️",
".zip" or ".rar" or ".7z" => "📦",
".txt" => "📃",
_ => "📎"
};
}
}
[NotMapped]
public bool IsImage => ContentType.StartsWith("image/");
/// <summary>
/// Whether the file is stored on disk (vs legacy database storage)
/// </summary>
[NotMapped]
public bool IsFileBasedStorage => !string.IsNullOrEmpty(FilePath);
/// <summary>
/// Maximum file size allowed (10 MB)
/// </summary>
public const long MaxFileSize = 10 * 1024 * 1024;
}
+45
View File
@@ -0,0 +1,45 @@
using System.ComponentModel.DataAnnotations;
namespace Lagerbestand.Models;
/// <summary>
/// Type of comment (chat message or system activity)
/// </summary>
public enum KanbanCommentType
{
Chat = 0,
Activity = 1
}
/// <summary>
/// Represents a chat message or activity log entry on a Kanban task
/// </summary>
public class KanbanTaskComment
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid TaskId { get; set; }
/// <summary>
/// User who created the comment (null for system activities)
/// </summary>
public Guid? UserId { get; set; }
[Required]
public string Content { get; set; } = string.Empty;
/// <summary>
/// Whether this is a chat message or system activity
/// </summary>
public KanbanCommentType CommentType { get; set; } = KanbanCommentType.Chat;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation properties
public virtual KanbanTask? Task { get; set; }
public virtual AppUser? User { get; set; }
// Computed properties
public bool IsActivity => CommentType == KanbanCommentType.Activity;
public bool IsChat => CommentType == KanbanCommentType.Chat;
}
+36
View File
@@ -0,0 +1,36 @@
using System.ComponentModel.DataAnnotations;
namespace Lagerbestand.Models;
/// <summary>
/// Represents a shared editable note on a Kanban task (like MS Teams wiki)
/// </summary>
public class KanbanTaskNote
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid TaskId { get; set; }
[Required]
[MaxLength(100)]
public string Title { get; set; } = "Notizen";
/// <summary>
/// Rich text content of the note
/// </summary>
public string Content { get; set; } = string.Empty;
public int SortOrder { get; set; }
/// <summary>
/// Last user who edited the note
/// </summary>
public Guid? LastEditedByUserId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
// Navigation properties
public virtual KanbanTask? Task { get; set; }
public virtual AppUser? LastEditedByUser { get; set; }
}
@@ -0,0 +1,57 @@
namespace Lagerbestand.Models;
/// <summary>
/// Many-to-many relationship between KanbanTask and KanbanLabel
/// </summary>
public class KanbanTaskLabel
{
public Guid TaskId { get; set; }
public Guid LabelId { get; set; }
// Navigation properties
public virtual KanbanTask? Task { get; set; }
public virtual KanbanLabel? Label { get; set; }
}
/// <summary>
/// Many-to-many relationship between KanbanTask and User (assignees)
/// </summary>
public class KanbanTaskAssignee
{
public Guid TaskId { get; set; }
public Guid UserId { get; set; }
public DateTime AssignedAt { get; set; } = DateTime.UtcNow;
// Navigation properties
public virtual KanbanTask? Task { get; set; }
public virtual AppUser? User { get; set; }
}
/// <summary>
/// Many-to-many relationship between KanbanTask and Customer
/// </summary>
public class KanbanTaskCustomer
{
public Guid TaskId { get; set; }
public Guid CustomerId { get; set; }
public Guid? VehicleId { get; set; }
// Navigation properties
public virtual KanbanTask? Task { get; set; }
public virtual Customer? Customer { get; set; }
public virtual Vehicle? Vehicle { get; set; }
}
/// <summary>
/// Many-to-many relationship between KanbanTask and TireStack
/// </summary>
public class KanbanTaskTireStack
{
public Guid TaskId { get; set; }
public Guid TireStackId { get; set; }
// Navigation properties
public virtual KanbanTask? Task { get; set; }
public virtual TireStack? TireStack { get; set; }
}
+92
View File
@@ -0,0 +1,92 @@
using System.ComponentModel.DataAnnotations;
namespace Lagerbestand.Models;
/// <summary>
/// Represents a task template that can be used to quickly create new tasks
/// </summary>
public class KanbanTaskTemplate
{
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[MaxLength(100)]
public string Name { get; set; } = string.Empty;
[MaxLength(500)]
public string? Description { get; set; }
/// <summary>
/// Title pattern for tasks created from this template
/// Can include placeholders like {Kunde}
/// </summary>
[MaxLength(200)]
public string TitlePattern { get; set; } = string.Empty;
/// <summary>
/// Default description for tasks
/// </summary>
public string? DefaultDescription { get; set; }
/// <summary>
/// Default column for tasks created from this template
/// </summary>
public Guid? DefaultColumnId { get; set; }
/// <summary>
/// Default priority for tasks
/// </summary>
public KanbanTaskPriority DefaultPriority { get; set; } = KanbanTaskPriority.Medium;
/// <summary>
/// Default estimated minutes
/// </summary>
public int? DefaultEstimatedMinutes { get; set; }
/// <summary>
/// Display order for templates
/// </summary>
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
// Navigation properties
public virtual KanbanColumn? DefaultColumn { get; set; }
public virtual ICollection<KanbanTaskTemplateTodo> Todos { get; set; } = new List<KanbanTaskTemplateTodo>();
public virtual ICollection<KanbanTaskTemplateLabel> TemplateLabels { get; set; } = new List<KanbanTaskTemplateLabel>();
}
/// <summary>
/// Predefined todo item for a template
/// </summary>
public class KanbanTaskTemplateTodo
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid TemplateId { get; set; }
[Required]
[MaxLength(500)]
public string Text { get; set; } = string.Empty;
public int SortOrder { get; set; }
// Navigation properties
public virtual KanbanTaskTemplate? Template { get; set; }
}
/// <summary>
/// Default label for a template (many-to-many)
/// </summary>
public class KanbanTaskTemplateLabel
{
public Guid TemplateId { get; set; }
public Guid LabelId { get; set; }
// Navigation properties
public virtual KanbanTaskTemplate? Template { get; set; }
public virtual KanbanLabel? Label { get; set; }
}
+27
View File
@@ -0,0 +1,27 @@
using System.ComponentModel.DataAnnotations;
namespace Lagerbestand.Models;
/// <summary>
/// Represents a todo/checklist item within a Kanban task
/// </summary>
public class KanbanTaskTodo
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid TaskId { get; set; }
[Required]
[MaxLength(500)]
public string Text { get; set; } = string.Empty;
public bool IsCompleted { get; set; }
public int SortOrder { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? CompletedAt { get; set; }
// Navigation properties
public virtual KanbanTask? Task { get; set; }
}
+113
View File
@@ -0,0 +1,113 @@
namespace Lagerbestand.Models;
/// <summary>
/// Represents the position within a shelf slot (side + level).
/// The value corresponds to position C in the location code (ABCD format).
/// </summary>
public enum ShelfPosition
{
/// <summary>
/// Links oben - Code: 1
/// </summary>
LinksOben = 1,
/// <summary>
/// Links unten - Code: 2
/// </summary>
LinksUnten = 2,
/// <summary>
/// Rechts oben - Code: 3
/// </summary>
RechtsOben = 3,
/// <summary>
/// Rechts unten - Code: 4
/// </summary>
RechtsUnten = 4
}
/// <summary>
/// Extension methods for ShelfPosition enum.
/// </summary>
public static class ShelfPositionExtensions
{
/// <summary>
/// Gets the German display name for the shelf position.
/// </summary>
public static string GetDisplayName(this ShelfPosition position) => position switch
{
ShelfPosition.LinksOben => "Links oben",
ShelfPosition.LinksUnten => "Links unten",
ShelfPosition.RechtsOben => "Rechts oben",
ShelfPosition.RechtsUnten => "Rechts unten",
_ => position.ToString()
};
/// <summary>
/// Gets a short display name (e.g., "L↑") for compact grid views.
/// </summary>
public static string GetShortName(this ShelfPosition position) => position switch
{
ShelfPosition.LinksOben => "L↑",
ShelfPosition.LinksUnten => "L↓",
ShelfPosition.RechtsOben => "R↑",
ShelfPosition.RechtsUnten => "R↓",
_ => position.ToString()
};
/// <summary>
/// Whether the position is on the left side of the shelf.
/// </summary>
public static bool IsLeft(this ShelfPosition position) =>
position is ShelfPosition.LinksOben or ShelfPosition.LinksUnten;
/// <summary>
/// Whether the position is on the top level of the shelf.
/// </summary>
public static bool IsTop(this ShelfPosition position) =>
position is ShelfPosition.LinksOben or ShelfPosition.RechtsOben;
/// <summary>
/// Gets the single-digit code for the position.
/// </summary>
public static string GetCode(this ShelfPosition position) => ((int)position).ToString();
/// <summary>
/// Parses a code digit to ShelfPosition.
/// </summary>
/// <param name="code">Single digit code (1-4)</param>
/// <exception cref="ArgumentException">If code is not valid</exception>
public static ShelfPosition FromCode(char code) => code switch
{
'1' => ShelfPosition.LinksOben,
'2' => ShelfPosition.LinksUnten,
'3' => ShelfPosition.RechtsOben,
'4' => ShelfPosition.RechtsUnten,
_ => throw new ArgumentException($"Ungültiger Positionscode: {code}. Erlaubt: 1-4", nameof(code))
};
/// <summary>
/// Tries to parse a code digit to ShelfPosition.
/// </summary>
public static bool TryFromCode(char code, out ShelfPosition position)
{
position = default;
if (code is < '1' or > '4')
return false;
position = (ShelfPosition)(code - '0');
return true;
}
/// <summary>
/// All shelf positions in display order.
/// </summary>
public static readonly ShelfPosition[] All =
[
ShelfPosition.LinksOben,
ShelfPosition.LinksUnten,
ShelfPosition.RechtsOben,
ShelfPosition.RechtsUnten
];
}
+189
View File
@@ -0,0 +1,189 @@
namespace Lagerbestand.Models;
/// <summary>
/// Site-wide settings stored in the database
/// </summary>
public class SiteSettings
{
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>
/// Unique key for the setting (e.g., "PromoBanner")
/// </summary>
public string Key { get; set; } = string.Empty;
/// <summary>
/// JSON-serialized value of the setting
/// </summary>
public string Value { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
}
/// <summary>
/// Promotion banner settings
/// </summary>
public class PromoBannerSettings
{
public bool IsEnabled { get; set; }
public string Title { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public DateTime? StartDate { get; set; }
public DateTime? EndDate { get; set; }
public bool ShowContactButton { get; set; } = true;
/// <summary>
/// Check if banner should be shown based on current date and content
/// </summary>
public bool ShouldShow()
{
// Must be enabled
if (!IsEnabled) return false;
// Must have content
if (string.IsNullOrWhiteSpace(Title) && string.IsNullOrWhiteSpace(Message))
return false;
var now = DateTime.UtcNow.Date;
// Check date range if specified
if (StartDate.HasValue && now < StartDate.Value.Date)
return false;
if (EndDate.HasValue && now > EndDate.Value.Date)
return false;
return true;
}
}
/// <summary>
/// Lexware API connection settings for customer data synchronization
/// </summary>
public class LexwareApiSettings
{
public bool IsEnabled { get; set; }
/// <summary>
/// Lexware API base URL (e.g., https://api.lexoffice.io/v1)
/// </summary>
public string BaseUrl { get; set; } = string.Empty;
/// <summary>
/// API key for authentication
/// </summary>
public string ApiKey { get; set; } = string.Empty;
/// <summary>
/// Whether automatic daily sync is enabled
/// </summary>
public bool AutoSyncEnabled { get; set; } = true;
/// <summary>
/// Time of day for automatic sync (HH:mm format)
/// </summary>
public string AutoSyncTime { get; set; } = "02:00";
/// <summary>
/// Timestamp of the last successful sync
/// </summary>
public DateTime? LastSyncAt { get; set; }
/// <summary>
/// Result message from the last sync
/// </summary>
public string? LastSyncResult { get; set; }
/// <summary>
/// Number of customers synced in last run
/// </summary>
public int LastSyncCount { get; set; }
/// <summary>
/// Whether the API connection is configured
/// </summary>
public bool IsConfigured => !string.IsNullOrWhiteSpace(BaseUrl) && !string.IsNullOrWhiteSpace(ApiKey);
}
/// <summary>
/// Two-Factor Authentication settings stored in SiteSettings
/// </summary>
public class TwoFactorSettings
{
/// <summary>
/// Global toggle: when false, no 2FA is used at all
/// </summary>
public bool IsEnabled { get; set; }
/// <summary>
/// Issuer name shown in authenticator apps (e.g. "Reifen Späth")
/// </summary>
public string Issuer { get; set; } = "Reifen Späth Lagerverwaltung";
/// <summary>
/// Whether email fallback codes are enabled
/// </summary>
public bool EmailFallbackEnabled { get; set; }
}
/// <summary>
/// Session / long-duration usage settings (idle lock, circuit retention).
/// Editable by admins in the Status page.
/// </summary>
public class SessionSettings
{
public const int DefaultIdleTimeoutMinutes = 60;
public const int MaxIdleTimeoutMinutes = 60;
public const int MinIdleTimeoutMinutes = 1;
public const double DefaultCircuitRetentionHours = 2.0;
public const double MaxCircuitRetentionHours = 4.0;
public const double MinCircuitRetentionHours = 0.5;
/// <summary>
/// Minutes of inactivity before the browser tab shows a "Confirm to continue" lock overlay.
/// Range: 1 – 60. Default: 60.
/// </summary>
public int IdleTimeoutMinutes { get; set; } = DefaultIdleTimeoutMinutes;
/// <summary>
/// Hours a disconnected Blazor Server circuit is retained on the server
/// before being discarded (allows reconnect without state loss).
/// Range: 0.5 – 4. Default: 2. Applied at next app restart.
/// </summary>
public double CircuitRetentionHours { get; set; } = DefaultCircuitRetentionHours;
public int GetSafeIdleTimeoutMinutes()
{
if (IdleTimeoutMinutes < MinIdleTimeoutMinutes) return MinIdleTimeoutMinutes;
if (IdleTimeoutMinutes > MaxIdleTimeoutMinutes) return MaxIdleTimeoutMinutes;
return IdleTimeoutMinutes;
}
public double GetSafeCircuitRetentionHours()
{
if (CircuitRetentionHours < MinCircuitRetentionHours) return MinCircuitRetentionHours;
if (CircuitRetentionHours > MaxCircuitRetentionHours) return MaxCircuitRetentionHours;
return CircuitRetentionHours;
}
}
/// <summary>
/// SMTP settings for sending email-based 2FA codes and notifications
/// </summary>
public class SmtpSettings
{
public bool IsEnabled { get; set; }
public string Host { get; set; } = string.Empty;
public int Port { get; set; } = 587;
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string FromEmail { get; set; } = string.Empty;
public string FromName { get; set; } = "Reifen Späth Lagerverwaltung";
public bool UseSsl { get; set; } = true;
public bool IsConfigured => !string.IsNullOrWhiteSpace(Host)
&& !string.IsNullOrWhiteSpace(Username)
&& !string.IsNullOrWhiteSpace(FromEmail);
}
+252
View File
@@ -0,0 +1,252 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.RegularExpressions;
namespace Lagerbestand.Models;
/// <summary>
/// Represents a physical storage location in the warehouse.
/// Storage locations are identified by a 4-digit code in ABCD format:
/// - A: Gebäude (1=Werkstatt mit 3 Reihen, 2=Lager mit 5 Reihen)
/// - B: Regalreihe (Werkstatt: 1-3, Lager: 1-5)
/// - C: Seite/Ebene (1=links oben, 2=links unten, 3=rechts oben, 4=rechts unten)
/// - D: Platz längs (1-9)
/// Example: 2235 = Lager, Reihe 2, rechts oben, Platz 5
/// </summary>
public class StorageLocation
{
/// <summary>
/// Regex pattern for valid location codes: ABCD format
/// A = 1-2 (Gebäude), B = 1-5 (Reihe), C = 1-4 (Seite/Ebene), D = 1-9 (Platz)
/// </summary>
public const string LocationCodePattern = @"^[12][1-5][1-4][1-9]$";
/// <summary>
/// Minimum row number
/// </summary>
public const int MinRow = 1;
/// <summary>
/// Maximum row number (Lager, Gebäude 2)
/// </summary>
public const int MaxRow = 5;
/// <summary>
/// Maximum row number for Werkstatt (Gebäude 1)
/// </summary>
public const int MaxRowWerkstatt = 3;
/// <summary>
/// Maximum row number for Lager (Gebäude 2)
/// </summary>
public const int MaxRowLager = 5;
/// <summary>
/// Minimum slot number (längs)
/// </summary>
public const int MinSlot = 1;
/// <summary>
/// Maximum slot number (längs)
/// </summary>
public const int MaxSlot = 9;
/// <summary>
/// Number of positions per lengthwise slot (links/rechts × oben/unten)
/// </summary>
public const int PositionsPerSlot = 4;
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>
/// Unique 4-digit location code in ABCD format (e.g., "2235").
/// This is the primary human-readable identifier.
/// </summary>
[Required]
[MaxLength(4)]
[RegularExpression(LocationCodePattern, ErrorMessage = "Ungültiges Code-Format. Erwartet: ABCD (z.B. 2235)")]
public string Code { get; set; } = string.Empty;
/// <summary>
/// Storage building (Werkstatt=1, Lager=2).
/// Corresponds to position A in the location code.
/// </summary>
[Required]
public AreaType Area { get; set; } = AreaType.Lager;
/// <summary>
/// Shelf row number within the building (Werkstatt: 1-3, Lager: 1-5).
/// Corresponds to position B in the location code.
/// </summary>
[Required]
[Range(MinRow, MaxRow, ErrorMessage = "Reihe muss zwischen 1 und 5 liegen.")]
public int Row { get; set; } = 1;
/// <summary>
/// Position within the shelf slot: side (links/rechts) and level (oben/unten).
/// Corresponds to position C in the location code.
/// </summary>
[Required]
public ShelfPosition Position { get; set; } = ShelfPosition.LinksOben;
/// <summary>
/// Lengthwise slot number within the row (1-9).
/// Corresponds to position D in the location code.
/// </summary>
[Required]
[Range(MinSlot, MaxSlot, ErrorMessage = "Platz muss zwischen 1 und 9 liegen.")]
public int SlotNumber { get; set; } = 1;
/// <summary>
/// Whether this location is currently occupied by a tire stack.
/// </summary>
public bool IsOccupied { get; set; }
/// <summary>
/// The ID of the tire stack currently stored at this location (if occupied).
/// </summary>
public Guid? TireStackId { get; set; }
/// <summary>
/// Navigation property to the tire stack at this location.
/// </summary>
[ForeignKey(nameof(TireStackId))]
public TireStack? OccupiedTireStack { get; set; }
/// <summary>
/// When the current tire stack was placed at this location.
/// </summary>
public DateTime? OccupiedSince { get; set; }
/// <summary>
/// Optional notes about this location.
/// </summary>
[MaxLength(500)]
public string? Notes { get; set; }
/// <summary>
/// Whether this location is available for new storage (not blocked).
/// </summary>
public bool IsAvailable { get; set; } = true;
/// <summary>
/// Whether this location is active in the system.
/// </summary>
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
/// <summary>
/// Navigation property for tire stacks stored at this location (legacy support).
/// </summary>
public ICollection<TireStack> TireStacks { get; set; } = new List<TireStack>();
/// <summary>
/// Display name showing the human-readable location.
/// </summary>
[NotMapped]
public string DisplayName => $"{Area.GetDisplayName()}, Reihe {Row}, {Position.GetDisplayName()}, Platz {SlotNumber}";
/// <summary>
/// Short display for compact views.
/// </summary>
[NotMapped]
public string ShortDisplay => $"{Area.GetDisplayName()} R{Row} {Position.GetShortName()} P{SlotNumber}";
/// <summary>
/// Full path description of the location.
/// </summary>
[NotMapped]
public string FullPath => $"{Area.GetBuildingLabel()} / Reihe {Row} / {Position.GetDisplayName()} / Platz {SlotNumber}";
/// <summary>
/// Current occupancy count (number of stacks stored).
/// </summary>
[NotMapped]
public int CurrentOccupancy => IsOccupied ? 1 : 0;
/// <summary>
/// Capacity is always 1 for the new system (one stack per location).
/// </summary>
[NotMapped]
public int Capacity => 1;
/// <summary>
/// Generates the location code from the component parts.
/// </summary>
public void GenerateCode()
{
Code = $"{(int)Area}{Row}{(int)Position}{SlotNumber}";
}
/// <summary>
/// Parses the location code and populates the Area, Row, Position, and SlotNumber properties.
/// </summary>
/// <returns>True if parsing was successful</returns>
public bool ParseCode()
{
if (string.IsNullOrWhiteSpace(Code) || Code.Length != 4)
return false;
if (!Regex.IsMatch(Code, LocationCodePattern))
return false;
if (!AreaTypeExtensions.TryFromCode(Code[0], out var area))
return false;
if (!int.TryParse(Code[1].ToString(), out var row) || row < MinRow || row > area.GetMaxRow())
return false;
if (!ShelfPositionExtensions.TryFromCode(Code[2], out var position))
return false;
if (!int.TryParse(Code[3].ToString(), out var slot) || slot < MinSlot || slot > MaxSlot)
return false;
Area = area;
Row = row;
Position = position;
SlotNumber = slot;
return true;
}
/// <summary>
/// Validates that the current Code matches the Area, Row, Position, and SlotNumber values.
/// </summary>
public bool ValidateCodeConsistency()
{
var expectedCode = $"{(int)Area}{Row}{(int)Position}{SlotNumber}";
return Code == expectedCode;
}
/// <summary>
/// Assigns a tire stack to this location.
/// </summary>
/// <param name="tireStackId">The ID of the tire stack to assign</param>
/// <exception cref="InvalidOperationException">If location is already occupied</exception>
public void AssignTireStack(Guid tireStackId)
{
if (IsOccupied)
throw new InvalidOperationException($"Lagerplatz {Code} ist bereits belegt.");
TireStackId = tireStackId;
IsOccupied = true;
OccupiedSince = DateTime.UtcNow;
UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// Clears the tire stack from this location.
/// </summary>
public void ClearLocation()
{
TireStackId = null;
OccupiedTireStack = null;
IsOccupied = false;
OccupiedSince = null;
UpdatedAt = DateTime.UtcNow;
}
}
+111
View File
@@ -0,0 +1,111 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Lagerbestand.Models;
/// <summary>
/// Tire position in a stack
/// </summary>
public enum TirePosition
{
FrontLeft = 0,
FrontRight = 1,
RearLeft = 2,
RearRight = 3
}
/// <summary>
/// Tire condition rating
/// </summary>
public enum TireCondition
{
Excellent = 0, // Like new
Good = 1, // Normal wear
Fair = 2, // Noticeable wear but safe
Poor = 3, // Should be replaced soon
Critical = 4 // Unsafe, needs replacement
}
/// <summary>
/// Represents an individual tire within a tire stack.
/// Allows detailed tracking of each tire's condition.
/// A TireStack always has exactly 4 TireItems.
/// </summary>
public class TireItem
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>
/// The tire stack this item belongs to
/// </summary>
public Guid TireStackId { get; set; }
[ForeignKey(nameof(TireStackId))]
public TireStack? TireStack { get; set; }
/// <summary>
/// Position of this tire in the stack
/// </summary>
public TirePosition Position { get; set; }
/// <summary>
/// Individual tread depth for this tire in mm
/// </summary>
[Range(0, 10)]
public decimal? TreadDepth { get; set; }
/// <summary>
/// Condition rating for this tire
/// </summary>
public TireCondition Condition { get; set; } = TireCondition.Good;
/// <summary>
/// Any damage or issues noted
/// </summary>
[MaxLength(500)]
public string? DamageNotes { get; set; }
/// <summary>
/// Individual DOT code if different from stack
/// </summary>
[MaxLength(20)]
public string? DotCode { get; set; }
/// <summary>
/// Photo filename for tire condition documentation
/// </summary>
[MaxLength(500)]
public string? PhotoFileName { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
/// <summary>
/// Position display text in German
/// </summary>
[NotMapped]
public string PositionText => Position switch
{
TirePosition.FrontLeft => "Vorne Links",
TirePosition.FrontRight => "Vorne Rechts",
TirePosition.RearLeft => "Hinten Links",
TirePosition.RearRight => "Hinten Rechts",
_ => "Unbekannt"
};
/// <summary>
/// Condition display text in German
/// </summary>
[NotMapped]
public string ConditionText => Condition switch
{
TireCondition.Excellent => "Sehr gut",
TireCondition.Good => "Gut",
TireCondition.Fair => "Ausreichend",
TireCondition.Poor => "Mangelhaft",
TireCondition.Critical => "Kritisch",
_ => "Unbekannt"
};
}
+259
View File
@@ -0,0 +1,259 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Lagerbestand.Models;
/// <summary>
/// Status of a tire stack in the storage system
/// </summary>
public enum TireStackStatus
{
/// <summary>
/// Tires are currently stored in the warehouse
/// </summary>
Stored = 0,
/// <summary>
/// Tires have been picked up by the customer
/// </summary>
PickedUp = 1,
/// <summary>
/// Tires have been disposed/recycled
/// </summary>
Disposed = 2,
/// <summary>
/// Tires are reserved for pickup
/// </summary>
Reserved = 3,
/// <summary>
/// Tires are in inventory (shop stock, not customer-owned)
/// </summary>
Inventory = 4
}
/// <summary>
/// Represents a stack of exactly 4 tires as a single indivisible unit.
/// A stack is always 4 identical tires - there is no variable count.
/// This is the main entity that tracks customer tire storage and shop inventory.
/// </summary>
public class TireStack
{
/// <summary>
/// Number of tires in a stack - always 4
/// </summary>
public const int TiresPerStack = 4;
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>
/// Custom item count for inventory (default: 4 = standard stack).
/// Only used for inventory items. Customer stacks always use TiresPerStack.
/// </summary>
[Range(1, 99)]
public int ItemCount { get; set; } = TiresPerStack;
/// <summary>
/// Unique identifier/ticket number for this tire stack (e.g., "TS-2024-0001")
/// This serves as the primary human-readable identifier.
/// </summary>
[Required]
[MaxLength(50)]
public string TicketNumber { get; set; } = string.Empty;
/// <summary>
/// Customer who owns these tires (null for shop inventory)
/// </summary>
public Guid? CustomerId { get; set; }
[ForeignKey(nameof(CustomerId))]
public Customer? Customer { get; set; }
/// <summary>
/// Vehicle these tires belong to (null for shop inventory or legacy data)
/// </summary>
public Guid? VehicleId { get; set; }
[ForeignKey(nameof(VehicleId))]
public Vehicle? Vehicle { get; set; }
/// <summary>
/// Current storage location (null if picked up)
/// </summary>
public Guid? StorageLocationId { get; set; }
[ForeignKey(nameof(StorageLocationId))]
public StorageLocation? StorageLocation { get; set; }
/// <summary>
/// Tire category/type (used for inventory categorization)
/// </summary>
public Guid? CatalogItemId { get; set; }
[ForeignKey(nameof(CatalogItemId))]
public CatalogItem? CatalogItem { get; set; }
/// <summary>
/// Current status of the tire stack
/// </summary>
public TireStackStatus Status { get; set; } = TireStackStatus.Stored;
/// <summary>
/// Tire size (e.g., "225/45R17")
/// </summary>
[MaxLength(50)]
public string? TireSize { get; set; }
/// <summary>
/// Tire brand (e.g., "Michelin", "Continental")
/// </summary>
[MaxLength(100)]
public string? TireBrand { get; set; }
/// <summary>
/// Tire model (e.g., "Pilot Sport 4", "WinterContact TS 860")
/// </summary>
[MaxLength(100)]
public string? TireModel { get; set; }
/// <summary>
/// Season type
/// </summary>
public TireSeason Season { get; set; } = TireSeason.Sommer;
/// <summary>
/// Tread depth in mm (average of all 4 tires)
/// </summary>
[Range(0, 10)]
public decimal? TreadDepth { get; set; }
/// <summary>
/// DOT/manufacturing date code (e.g., "2523" = week 25 of 2023)
/// </summary>
[MaxLength(20)]
public string? DotCode { get; set; }
/// <summary>
/// Whether tires are mounted on rims
/// </summary>
public bool WithRims { get; set; } = true;
/// <summary>
/// Rim size if with rims (e.g., "17 Zoll")
/// </summary>
[MaxLength(20)]
public string? RimSize { get; set; }
/// <summary>
/// TPMS sensors present
/// </summary>
public bool HasTpms { get; set; }
/// <summary>
/// Tire condition rating
/// </summary>
public TireCondition Condition { get; set; } = TireCondition.Good;
/// <summary>
/// Additional notes about the tire condition or special requirements
/// </summary>
[MaxLength(1000)]
public string? Notes { get; set; }
/// <summary>
/// Date when tires were stored
/// </summary>
public DateTime StoredAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// Date when tires were picked up
/// </summary>
public DateTime? PickedUpAt { get; set; }
/// <summary>
/// Expected pickup date (next season change)
/// </summary>
public DateTime? ExpectedPickupDate { get; set; }
/// <summary>
/// User who stored the tires
/// </summary>
public Guid? StoredByUserId { get; set; }
/// <summary>
/// User who processed the pickup
/// </summary>
public Guid? PickedUpByUserId { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
/// <summary>
/// Whether this is shop inventory (not customer-owned)
/// </summary>
[NotMapped]
public bool IsInventory => CustomerId == null;
/// <summary>
/// Display name - customer name or category name for inventory
/// </summary>
[NotMapped]
public string DisplayName => Customer?.FullName ?? CatalogItem?.Name ?? "Lagerbestand";
/// <summary>
/// Summary description for display
/// </summary>
[NotMapped]
public string Summary
{
get
{
var parts = new List<string>();
if (!string.IsNullOrEmpty(TireBrand)) parts.Add(TireBrand);
if (!string.IsNullOrEmpty(TireSize)) parts.Add(TireSize);
parts.Add(Season.ToString());
if (WithRims) parts.Add("mit Felgen");
return string.Join(" | ", parts);
}
}
/// <summary>
/// Status display text in German
/// </summary>
[NotMapped]
public string StatusText => Status switch
{
TireStackStatus.Stored => "Eingelagert",
TireStackStatus.PickedUp => "Abgeholt",
TireStackStatus.Disposed => "Entsorgt",
TireStackStatus.Reserved => "Reserviert",
TireStackStatus.Inventory => "Lagerbestand",
_ => "Unbekannt"
};
/// <summary>
/// Days stored (for billing purposes)
/// </summary>
[NotMapped]
public int DaysStored => (PickedUpAt ?? DateTime.UtcNow).Subtract(StoredAt).Days;
/// <summary>
/// Condition display text in German
/// </summary>
[NotMapped]
public string ConditionText => Condition switch
{
TireCondition.Excellent => "Sehr gut",
TireCondition.Good => "Gut",
TireCondition.Fair => "Ausreichend",
TireCondition.Poor => "Mangelhaft",
TireCondition.Critical => "Kritisch",
_ => "Unbekannt"
};
}
+92
View File
@@ -0,0 +1,92 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Lagerbestand.Models;
/// <summary>
/// Represents a vehicle owned by a customer.
/// A customer can have multiple vehicles, each with their own tire stacks.
/// </summary>
public class Vehicle
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>
/// The customer who owns this vehicle
/// </summary>
public Guid CustomerId { get; set; }
[ForeignKey(nameof(CustomerId))]
public Customer? Customer { get; set; }
/// <summary>
/// Vehicle manufacturer (e.g., "BMW", "Mercedes", "VW")
/// </summary>
[Required]
[MaxLength(100)]
public string Make { get; set; } = string.Empty;
/// <summary>
/// Vehicle model (e.g., "3er", "C-Klasse", "Golf")
/// </summary>
[MaxLength(100)]
public string? Model { get; set; }
/// <summary>
/// Tire size for this vehicle (e.g., "225/45 R17")
/// </summary>
[Required]
[MaxLength(50)]
public string TireSize { get; set; } = string.Empty;
/// <summary>
/// Additional notes about the vehicle
/// </summary>
[MaxLength(500)]
public string? Notes { get; set; }
/// <summary>
/// Whether this is the customer's primary/default vehicle
/// </summary>
public bool IsPrimary { get; set; }
/// <summary>
/// Whether the vehicle is active (not deleted)
/// </summary>
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
/// <summary>
/// Navigation property for tire stacks stored for this vehicle
/// </summary>
public ICollection<TireStack> TireStacks { get; set; } = new List<TireStack>();
/// <summary>
/// Display name combining make, model, and tire size
/// </summary>
[NotMapped]
public string DisplayName
{
get
{
var name = Make;
if (!string.IsNullOrEmpty(Model))
name += $" {Model}";
if (!string.IsNullOrEmpty(TireSize))
name += $" ({TireSize})";
return name;
}
}
/// <summary>
/// Short display showing just make (for compact views)
/// </summary>
[NotMapped]
public string ShortName => !string.IsNullOrEmpty(Model)
? $"{Make} {Model}"
: Make;
}