|
2 | 2 | @rendermode InteractiveServer |
3 | 3 | @using MoveIT.Models |
4 | 4 | @using MoveIT.Services |
| 5 | +@using Microsoft.AspNetCore.Components.Forms |
| 6 | +@using SixLabors.ImageSharp |
| 7 | +@using SixLabors.ImageSharp.Processing |
| 8 | +@using SixLabors.ImageSharp.Formats.Jpeg |
5 | 9 | @inject InventoryService Svc |
6 | 10 |
|
7 | 11 | <PageTitle>Inventory — MoveIT</PageTitle> |
8 | 12 |
|
9 | 13 | <div class="d-flex justify-content-between align-items-center mb-3"> |
10 | 14 | <h1 class="h3 mb-0">Moving Inventory</h1> |
11 | | - <button class="btn btn-primary" @onclick="OpenAdd">+ Add Item</button> |
| 15 | + <div class="d-flex gap-2"> |
| 16 | + <button class="btn btn-success" @onclick="OpenQuickAdd">📸 Quick Add</button> |
| 17 | + <button class="btn btn-primary" @onclick="OpenAdd">+ Add Item</button> |
| 18 | + </div> |
12 | 19 | </div> |
13 | 20 |
|
14 | 21 | <!-- Stats --> |
|
119 | 126 | <table class="table table-hover align-middle"> |
120 | 127 | <thead class="table-light"> |
121 | 128 | <tr> |
| 129 | + <th style="width:72px"></th> |
122 | 130 | <th>Name</th> |
123 | 131 | <th>Category</th> |
124 | 132 | <th>From → To</th> |
|
136 | 144 | @if (!FilteredItems.Any()) |
137 | 145 | { |
138 | 146 | <tr> |
139 | | - <td colspan="11" class="text-center text-muted py-5"> |
140 | | - No items yet. Click <strong>+ Add Item</strong> to start tracking. |
| 147 | + <td colspan="12" class="text-center text-muted py-5"> |
| 148 | + No items yet. Click <strong>+ Add Item</strong> or <strong>📸 Quick Add</strong> to start tracking. |
141 | 149 | </td> |
142 | 150 | </tr> |
143 | 151 | } |
144 | 152 | @foreach (var item in FilteredItems) |
145 | 153 | { |
146 | 154 | <tr> |
| 155 | + <td> |
| 156 | + @if (item.Photo is { Length: > 0 }) |
| 157 | + { |
| 158 | + <img src="/api/photos/@item.Id" alt="" |
| 159 | + style="width:56px;height:56px;object-fit:cover;border-radius:6px;cursor:pointer" |
| 160 | + @onclick="() => OpenPhotoPreview(item)" /> |
| 161 | + } |
| 162 | + else |
| 163 | + { |
| 164 | + <div class="text-muted small text-center" style="width:56px;height:56px;line-height:56px;background:#f3f4f6;border-radius:6px">—</div> |
| 165 | + } |
| 166 | + </td> |
147 | 167 | <td class="fw-semibold">@item.Name</td> |
148 | 168 | <td><span class="badge bg-secondary">@item.Category</span></td> |
149 | 169 | <td class="small text-nowrap"> |
|
292 | 312 | } |
293 | 313 | </div> |
294 | 314 |
|
| 315 | + <div class="col-12"> |
| 316 | + <label class="form-label fw-semibold">Photo</label> |
| 317 | + <div class="d-flex align-items-center gap-3"> |
| 318 | + @if (_form.Photo is { Length: > 0 }) |
| 319 | + { |
| 320 | + <img src="data:image/jpeg;base64,@Convert.ToBase64String(_form.Photo)" alt="" |
| 321 | + style="width:120px;height:120px;object-fit:cover;border-radius:8px" /> |
| 322 | + } |
| 323 | + else |
| 324 | + { |
| 325 | + <div class="text-muted small text-center" style="width:120px;height:120px;line-height:120px;background:#f3f4f6;border-radius:8px">No photo</div> |
| 326 | + } |
| 327 | + <div class="d-flex flex-column gap-2"> |
| 328 | + <InputFile OnChange="OnEditPhotoSelected" accept="image/*" capture="environment" class="form-control form-control-sm" /> |
| 329 | + @if (_form.Photo is { Length: > 0 }) |
| 330 | + { |
| 331 | + <button type="button" class="btn btn-outline-danger btn-sm" @onclick="ClearEditPhoto">Remove photo</button> |
| 332 | + } |
| 333 | + </div> |
| 334 | + </div> |
| 335 | + </div> |
| 336 | + |
295 | 337 | <div class="col-12"> |
296 | 338 | <label class="form-label fw-semibold">Notes</label> |
297 | 339 | <textarea class="form-control" @bind="_form.Notes" rows="2" |
|
317 | 359 | </div> |
318 | 360 | } |
319 | 361 |
|
| 362 | +<!-- Quick Add Modal --> |
| 363 | +@if (_showQuickAdd) |
| 364 | +{ |
| 365 | + <div class="modal show d-block" tabindex="-1" style="background:rgba(0,0,0,0.5)"> |
| 366 | + <div class="modal-dialog"> |
| 367 | + <div class="modal-content"> |
| 368 | + <div class="modal-header"> |
| 369 | + <h5 class="modal-title">📸 Quick Add</h5> |
| 370 | + <button type="button" class="btn-close" @onclick="CloseQuickAdd"></button> |
| 371 | + </div> |
| 372 | + <div class="modal-body"> |
| 373 | + <div class="mb-3 text-center"> |
| 374 | + @if (_quickForm.Photo is { Length: > 0 }) |
| 375 | + { |
| 376 | + <img src="data:image/jpeg;base64,@Convert.ToBase64String(_quickForm.Photo)" alt="" |
| 377 | + style="max-width:100%;max-height:280px;border-radius:8px" /> |
| 378 | + } |
| 379 | + else |
| 380 | + { |
| 381 | + <div class="text-muted d-flex align-items-center justify-content-center" |
| 382 | + style="height:200px;background:#f3f4f6;border-radius:8px"> |
| 383 | + Tap below to capture |
| 384 | + </div> |
| 385 | + } |
| 386 | + </div> |
| 387 | + <div class="mb-3"> |
| 388 | + <InputFile OnChange="OnQuickPhotoSelected" accept="image/*" capture="environment" |
| 389 | + class="form-control form-control-lg" /> |
| 390 | + </div> |
| 391 | + <div class="mb-3"> |
| 392 | + <label class="form-label fw-semibold">Name</label> |
| 393 | + <input class="form-control form-control-lg" @bind="_quickForm.Name" |
| 394 | + placeholder="What is it?" @ref="_quickNameInput" /> |
| 395 | + </div> |
| 396 | + <div class="mb-3"> |
| 397 | + <label class="form-label fw-semibold">Room</label> |
| 398 | + <input class="form-control" @bind="_quickForm.OriginRoom" list="rooms-list-quick" placeholder="Origin room" /> |
| 399 | + <datalist id="rooms-list-quick"> |
| 400 | + @foreach (var r in _commonRooms) |
| 401 | + { |
| 402 | + <option value="@r" /> |
| 403 | + } |
| 404 | + </datalist> |
| 405 | + </div> |
| 406 | + @if (_quickSavedCount > 0) |
| 407 | + { |
| 408 | + <div class="text-success small">✓ @_quickSavedCount saved this session</div> |
| 409 | + } |
| 410 | + </div> |
| 411 | + <div class="modal-footer"> |
| 412 | + <button class="btn btn-secondary" @onclick="CloseQuickAdd">Done</button> |
| 413 | + <button class="btn btn-success" @onclick="QuickSaveAndNext" |
| 414 | + disabled="@string.IsNullOrWhiteSpace(_quickForm.Name)"> |
| 415 | + Save & Next |
| 416 | + </button> |
| 417 | + </div> |
| 418 | + </div> |
| 419 | + </div> |
| 420 | + </div> |
| 421 | +} |
| 422 | + |
| 423 | +<!-- Photo Preview Modal --> |
| 424 | +@if (_photoPreview is not null) |
| 425 | +{ |
| 426 | + <div class="modal show d-block" tabindex="-1" style="background:rgba(0,0,0,0.85)" @onclick="() => _photoPreview = null"> |
| 427 | + <div class="modal-dialog modal-lg modal-dialog-centered"> |
| 428 | + <div class="modal-content bg-transparent border-0"> |
| 429 | + <img src="/api/photos/@_photoPreview.Id" alt="" |
| 430 | + style="max-width:100%;max-height:90vh;border-radius:8px" /> |
| 431 | + <div class="text-center text-white mt-2">@_photoPreview.Name</div> |
| 432 | + </div> |
| 433 | + </div> |
| 434 | + </div> |
| 435 | +} |
| 436 | + |
320 | 437 | <!-- Delete Confirm Modal --> |
321 | 438 | @if (_deleteTarget is not null) |
322 | 439 | { |
|
345 | 462 | private MovingItem _form = new(); |
346 | 463 | private MovingItem? _deleteTarget; |
347 | 464 |
|
| 465 | + private bool _showQuickAdd; |
| 466 | + private MovingItem _quickForm = new(); |
| 467 | + private int _quickSavedCount; |
| 468 | + private string _lastQuickRoom = ""; |
| 469 | + private MovingItem? _photoPreview; |
| 470 | + private ElementReference _quickNameInput; |
| 471 | + |
348 | 472 | private string _search = ""; |
349 | 473 | private string _filterCategory = ""; |
350 | 474 | private string _filterRoom = ""; |
|
435 | 559 | _editingId = null; |
436 | 560 | } |
437 | 561 |
|
| 562 | + private void OpenQuickAdd() |
| 563 | + { |
| 564 | + _quickForm = new MovingItem { OriginRoom = _lastQuickRoom }; |
| 565 | + _quickSavedCount = 0; |
| 566 | + _showQuickAdd = true; |
| 567 | + } |
| 568 | + |
| 569 | + private void CloseQuickAdd() |
| 570 | + { |
| 571 | + _showQuickAdd = false; |
| 572 | + _quickForm = new(); |
| 573 | + } |
| 574 | + |
| 575 | + private async Task QuickSaveAndNext() |
| 576 | + { |
| 577 | + if (string.IsNullOrWhiteSpace(_quickForm.Name)) return; |
| 578 | + _lastQuickRoom = _quickForm.OriginRoom; |
| 579 | + Svc.Add(_quickForm); |
| 580 | + _quickSavedCount++; |
| 581 | + // Reset for next snap, keep room. |
| 582 | + _quickForm = new MovingItem { OriginRoom = _lastQuickRoom }; |
| 583 | + } |
| 584 | + |
| 585 | + private async Task OnQuickPhotoSelected(InputFileChangeEventArgs e) |
| 586 | + { |
| 587 | + var bytes = await ProcessPhotoAsync(e.File); |
| 588 | + if (bytes is not null) _quickForm.Photo = bytes; |
| 589 | + } |
| 590 | + |
| 591 | + private async Task OnEditPhotoSelected(InputFileChangeEventArgs e) |
| 592 | + { |
| 593 | + var bytes = await ProcessPhotoAsync(e.File); |
| 594 | + if (bytes is not null) _form.Photo = bytes; |
| 595 | + } |
| 596 | + |
| 597 | + private void ClearEditPhoto() => _form.Photo = null; |
| 598 | + |
| 599 | + private void OpenPhotoPreview(MovingItem item) => _photoPreview = item; |
| 600 | + |
| 601 | + // Read uploaded image, resize to max 1600px wide, re-encode as JPEG ~80%. |
| 602 | + private static async Task<byte[]?> ProcessPhotoAsync(IBrowserFile? file) |
| 603 | + { |
| 604 | + if (file is null) return null; |
| 605 | + const int maxBytes = 20 * 1024 * 1024; |
| 606 | + await using var input = file.OpenReadStream(maxBytes); |
| 607 | + using var image = await Image.LoadAsync(input); |
| 608 | + if (image.Width > 1600) |
| 609 | + { |
| 610 | + image.Mutate(x => x.Resize(1600, 0)); |
| 611 | + } |
| 612 | + using var ms = new MemoryStream(); |
| 613 | + await image.SaveAsJpegAsync(ms, new JpegEncoder { Quality = 80 }); |
| 614 | + return ms.ToArray(); |
| 615 | + } |
| 616 | + |
438 | 617 | private static string FormatMinutes(int? minutes) |
439 | 618 | { |
440 | 619 | if (!minutes.HasValue || minutes.Value <= 0) return "—"; |
|
0 commit comments