Index.cshtml
13,606 bytes
| 1 | @model WebApp.Controllers.SettlementIndexViewModel |
|---|---|
| 2 | |
| 3 | @{ |
| 4 | ViewData["Title"] = Localizer["Settlement"]; |
| 5 | var maxBalance = Model.Balances.Any() ? Model.Balances.Max(b => Math.Abs(b.NetBalance)) : 1m; |
| 6 | } |
| 7 | |
| 8 | <!-- Page Header --> |
| 9 | <div class="sa-gradient-header"> |
| 10 | <div class="d-flex justify-content-between align-items-center flex-wrap gap-3"> |
| 11 | <div> |
| 12 | <h1 style="margin-bottom: 4px;">@Localizer["Settlement"]</h1> |
| 13 | <p class="text-muted mb-0"> |
| 14 | @Model.TripName |
| 15 | @if (Model.TripStatus != "Active" && Model.LatestPlan != null) |
| 16 | { |
| 17 | var statusBadge = Model.LatestPlan.Status switch |
| 18 | { |
| 19 | App.Domain.ESettlementStatus.Completed => "sa-badge-success", |
| 20 | App.Domain.ESettlementStatus.InProgress => "sa-badge-warning", |
| 21 | _ => "sa-badge-info" |
| 22 | }; |
| 23 | var statusIcon = Model.LatestPlan.Status switch |
| 24 | { |
| 25 | App.Domain.ESettlementStatus.Completed => "bi-check-circle-fill", |
| 26 | App.Domain.ESettlementStatus.InProgress => "bi-hourglass-split", |
| 27 | _ => "bi-lock-fill" |
| 28 | }; |
| 29 | <span class="sa-badge @statusBadge ms-2" style="font-size: 0.75rem;"> |
| 30 | <i class="bi @statusIcon me-1"></i>@WebApp.Helpers.EnumHelper.GetDisplayName(Model.LatestPlan.Status) |
| 31 | </span> |
| 32 | } |
| 33 | else if (Model.TripStatus != "Active") |
| 34 | { |
| 35 | <span class="sa-badge sa-badge-info ms-2" style="font-size: 0.75rem;"> |
| 36 | <i class="bi bi-lock-fill me-1"></i>@Localizer["Pending"] |
| 37 | </span> |
| 38 | } |
| 39 | </p> |
| 40 | </div> |
| 41 | <div class="d-flex gap-2"> |
| 42 | @if (Model.IsOrganizer && Model.TripStatus == "Active") |
| 43 | { |
| 44 | <form asp-action="Finalize" asp-route-tripId="@Model.TripId" method="post" style="display:inline"> |
| 45 | <button type="submit" class="sa-btn sa-btn-sm" style="background: #fff; color: var(--sa-success);"> |
| 46 | <i class="bi bi-check-circle"></i> @Localizer["Finalize Trip"] |
| 47 | </button> |
| 48 | </form> |
| 49 | } |
| 50 | @if (Model.IsOrganizer && Model.TripStatus != "Active" && Model.LatestPlan != null && Model.LatestPlan.Status != App.Domain.ESettlementStatus.Completed) |
| 51 | { |
| 52 | <form asp-action="Reopen" asp-route-tripId="@Model.TripId" method="post" style="display:inline"> |
| 53 | <button type="submit" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.15); color: rgba(255,255,255,0.8);"> |
| 54 | <i class="bi bi-unlock"></i> @Localizer["Reopen"] |
| 55 | </button> |
| 56 | </form> |
| 57 | } |
| 58 | <a asp-controller="Trips" asp-action="Details" asp-route-id="@Model.TripId" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.9);"> |
| 59 | <i class="bi bi-arrow-left"></i> |
| 60 | </a> |
| 61 | </div> |
| 62 | </div> |
| 63 | </div> |
| 64 | |
| 65 | <!-- Balances --> |
| 66 | <div class="sa-card-static mb-4"> |
| 67 | <div class="sa-card-header"> |
| 68 | <i class="bi bi-bar-chart me-2"></i>@Localizer["Balances"] |
| 69 | </div> |
| 70 | <div class="sa-card-body"> |
| 71 | @foreach (var balance in Model.Balances) |
| 72 | { |
| 73 | var pct = maxBalance > 0 ? (int)(Math.Abs(balance.NetBalance) * 45 / maxBalance) : 0; |
| 74 | <div class="d-flex align-items-center gap-3 mb-3"> |
| 75 | <div style="width: 120px; font-weight: 600; font-size: 0.9rem;" class="sa-truncate"> |
| 76 | @balance.UserName |
| 77 | </div> |
| 78 | <div style="flex: 1; position: relative; height: 28px; display: flex; align-items: center;"> |
| 79 | <!-- Center line --> |
| 80 | <div style="position: absolute; left: 50%; top: 2px; bottom: 2px; width: 2px; background: var(--sa-gray-200); transform: translateX(-50%);"></div> |
| 81 | @if (balance.NetBalance >= 0) |
| 82 | { |
| 83 | <div style="position: absolute; left: 50%; height: 20px; width: @pct%; background: linear-gradient(90deg, var(--sa-success), #16a34a); border-radius: 0 var(--sa-radius-full) var(--sa-radius-full) 0; transition: width 0.8s ease;"></div> |
| 84 | } |
| 85 | else |
| 86 | { |
| 87 | <div style="position: absolute; right: 50%; height: 20px; width: @pct%; background: linear-gradient(270deg, var(--sa-danger), #dc2626); border-radius: var(--sa-radius-full) 0 0 var(--sa-radius-full); transition: width 0.8s ease;"></div> |
| 88 | } |
| 89 | </div> |
| 90 | <div style="width: 100px; text-align: right;"> |
| 91 | <span class="sa-amount @(balance.NetBalance >= 0 ? "sa-amount-positive" : "sa-amount-negative")" style="font-size: 0.95rem;"> |
| 92 | @(balance.NetBalance >= 0 ? "+" : "")@Model.CurrencySymbol@balance.NetBalance.ToString("N2") |
| 93 | </span> |
| 94 | </div> |
| 95 | </div> |
| 96 | } |
| 97 | </div> |
| 98 | </div> |
| 99 | |
| 100 | <!-- Settlement Plan --> |
| 101 | @if (Model.LatestPlan != null && Model.LatestPlan.Payments != null && Model.LatestPlan.Payments.Any() && Model.TripStatus != "Active") |
| 102 | { |
| 103 | <div class="sa-card-static"> |
| 104 | <div class="sa-card-header d-flex justify-content-between align-items-center"> |
| 105 | <span><i class="bi bi-check2-circle me-2"></i>@Localizer["Settlement Plan"]</span> |
| 106 | @{ |
| 107 | var planBadge = Model.LatestPlan.Status switch |
| 108 | { |
| 109 | App.Domain.ESettlementStatus.Completed => "sa-badge-success", |
| 110 | App.Domain.ESettlementStatus.InProgress => "sa-badge-warning", |
| 111 | _ => "sa-badge-neutral" |
| 112 | }; |
| 113 | } |
| 114 | <span class="sa-badge @planBadge">@WebApp.Helpers.EnumHelper.GetDisplayName(Model.LatestPlan.Status)</span> |
| 115 | </div> |
| 116 | <div class="sa-card-body"> |
| 117 | <div class="d-flex flex-column gap-3"> |
| 118 | @foreach (var payment in Model.LatestPlan.Payments.OrderByDescending(p => p.Amount)) |
| 119 | { |
| 120 | var fromName = payment.FromUser != null ? $"{payment.FromUser.FirstName} {payment.FromUser.LastName}" : "?"; |
| 121 | var toName = payment.ToUser != null ? $"{payment.ToUser.FirstName} {payment.ToUser.LastName}" : "?"; |
| 122 | var fromInitial = payment.FromUser != null ? $"{payment.FromUser.FirstName?[0]}{payment.FromUser.LastName?[0]}" : "?"; |
| 123 | var toInitial = payment.ToUser != null ? $"{payment.ToUser.FirstName?[0]}{payment.ToUser.LastName?[0]}" : "?"; |
| 124 | |
| 125 | <div class="sa-settlement-card"> |
| 126 | <div class="d-flex align-items-center gap-2" style="min-width: 0; flex: 1;"> |
| 127 | <span class="sa-avatar sa-avatar-sm sa-avatar-5" style="border: none;" title="@fromName">@fromInitial</span> |
| 128 | <span class="sa-truncate" style="font-weight: 600; font-size: 0.9rem;">@fromName</span> |
| 129 | </div> |
| 130 | <div class="d-flex flex-column align-items-center" style="flex-shrink: 0;"> |
| 131 | <i class="bi bi-arrow-right sa-settlement-arrow"></i> |
| 132 | <span class="sa-settlement-amount">@Model.CurrencySymbol@payment.Amount.ToString("N2")</span> |
| 133 | </div> |
| 134 | <div class="d-flex align-items-center gap-2" style="min-width: 0; flex: 1; justify-content: flex-end;"> |
| 135 | <span class="sa-truncate" style="font-weight: 600; font-size: 0.9rem;">@toName</span> |
| 136 | <span class="sa-avatar sa-avatar-sm sa-avatar-2" style="border: none;" title="@toName">@toInitial</span> |
| 137 | </div> |
| 138 | <div style="flex-shrink: 0; min-width: 110px; text-align: right;"> |
| 139 | @if (payment.Status == App.Domain.EPaymentStatus.Confirmed) |
| 140 | { |
| 141 | <span class="sa-badge sa-badge-solid-success"><i class="bi bi-check2-all me-1"></i>@Localizer["Confirmed"]</span> |
| 142 | } |
| 143 | else if (payment.Status == App.Domain.EPaymentStatus.MarkedPaid) |
| 144 | { |
| 145 | @if (payment.ToUserId == Model.CurrentUserId) |
| 146 | { |
| 147 | <form asp-action="ConfirmReceipt" asp-route-paymentId="@payment.Id" method="post" style="display:inline"> |
| 148 | <button type="submit" class="sa-btn sa-btn-sm sa-btn-pill" style="background: var(--sa-info); color: #fff;"> |
| 149 | <i class="bi bi-hand-thumbs-up"></i> @Localizer["Confirm"] |
| 150 | </button> |
| 151 | </form> |
| 152 | } |
| 153 | else |
| 154 | { |
| 155 | <span class="sa-badge sa-badge-warning"><i class="bi bi-hourglass-split me-1"></i>@Localizer["Awaiting Confirmation"]</span> |
| 156 | } |
| 157 | } |
| 158 | else |
| 159 | { |
| 160 | @if (payment.FromUserId == Model.CurrentUserId) |
| 161 | { |
| 162 | <form asp-action="MarkPaid" asp-route-paymentId="@payment.Id" method="post" style="display:inline"> |
| 163 | <button type="submit" class="sa-btn sa-btn-success sa-btn-sm sa-btn-pill"> |
| 164 | <i class="bi bi-check-lg"></i> @Localizer["Mark Paid"] |
| 165 | </button> |
| 166 | </form> |
| 167 | } |
| 168 | else |
| 169 | { |
| 170 | <span class="sa-badge sa-badge-neutral"><i class="bi bi-clock me-1"></i>@Localizer["Pending"]</span> |
| 171 | } |
| 172 | } |
| 173 | </div> |
| 174 | </div> |
| 175 | } |
| 176 | </div> |
| 177 | |
| 178 | <div class="sa-divider"></div> |
| 179 | <div class="d-flex justify-content-between" style="font-weight: 700;"> |
| 180 | <span>@Localizer["Total"]</span> |
| 181 | <span>@Model.CurrencySymbol@Model.LatestPlan.TotalAmount.ToString("N2")</span> |
| 182 | </div> |
| 183 | </div> |
| 184 | </div> |
| 185 | } |
| 186 | else if (Model.PreviewPayments.Any()) |
| 187 | { |
| 188 | <div class="sa-card-static"> |
| 189 | <div class="sa-card-header d-flex justify-content-between align-items-center"> |
| 190 | <span><i class="bi bi-eye me-2"></i>@Localizer["Suggested Payments"]</span> |
| 191 | <span class="sa-badge sa-badge-neutral">@Localizer["Preview"]</span> |
| 192 | </div> |
| 193 | <div class="sa-card-body"> |
| 194 | <div class="d-flex flex-column gap-3"> |
| 195 | @foreach (var payment in Model.PreviewPayments.OrderByDescending(p => p.Amount)) |
| 196 | { |
| 197 | var fromInitial = payment.FromUserName.Length >= 2 ? payment.FromUserName[..2].ToUpper() : "?"; |
| 198 | var toInitial = payment.ToUserName.Length >= 2 ? payment.ToUserName[..2].ToUpper() : "?"; |
| 199 | |
| 200 | <div class="sa-settlement-card"> |
| 201 | <div class="d-flex align-items-center gap-2" style="min-width: 0; flex: 1;"> |
| 202 | <span class="sa-avatar sa-avatar-sm sa-avatar-5" style="border: none;">@fromInitial</span> |
| 203 | <span class="sa-truncate" style="font-weight: 600; font-size: 0.9rem;">@payment.FromUserName</span> |
| 204 | </div> |
| 205 | <div class="d-flex flex-column align-items-center" style="flex-shrink: 0;"> |
| 206 | <i class="bi bi-arrow-right sa-settlement-arrow"></i> |
| 207 | <span class="sa-settlement-amount">@Model.CurrencySymbol@payment.Amount.ToString("N2")</span> |
| 208 | </div> |
| 209 | <div class="d-flex align-items-center gap-2" style="min-width: 0; flex: 1; justify-content: flex-end;"> |
| 210 | <span class="sa-truncate" style="font-weight: 600; font-size: 0.9rem;">@payment.ToUserName</span> |
| 211 | <span class="sa-avatar sa-avatar-sm sa-avatar-2" style="border: none;">@toInitial</span> |
| 212 | </div> |
| 213 | </div> |
| 214 | } |
| 215 | </div> |
| 216 | <div class="sa-divider"></div> |
| 217 | <p style="font-size: 0.8rem; color: var(--sa-gray-400); margin: 0;"> |
| 218 | <i class="bi bi-info-circle me-1"></i>@Localizer["These are suggested payments. Finalize the trip to lock them in."] |
| 219 | </p> |
| 220 | </div> |
| 221 | </div> |
| 222 | } |
| 223 | else |
| 224 | { |
| 225 | <div class="sa-card-static"> |
| 226 | <div class="sa-empty" style="padding: var(--sa-space-8) var(--sa-space-4);"> |
| 227 | <div class="sa-empty-icon" style="font-size: 2.5rem; color: var(--sa-success);"><i class="bi bi-check-circle-fill"></i></div> |
| 228 | <div class="sa-empty-title" style="font-size: 1rem;">@Localizer["All settled up!"]</div> |
| 229 | <p class="sa-empty-text" style="font-size: 0.875rem;">@Localizer["No payments needed — everyone is even."]</p> |
| 230 | </div> |
| 231 | </div> |
| 232 | } |
| 233 | |