Initial commit: Lagerbestand Blazor Projekt
This commit is contained in:
commit
cba9414e92
105 files changed
+36431
No files matched your search
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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" }
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user