Compare commits

..

4 Commits

Author SHA1 Message Date
Chris Chen 2e226e60f5 update mobile view.
ci-cd-vm / ci-cd (push) Failing after 1m13s
2026-06-23 14:18:55 -07:00
Chris Chen 68649223d9 update mobile view. 2026-06-23 14:15:20 -07:00
Chris Chen 9d7c224ad2 Update user-portal.component.scss 2026-06-23 13:53:57 -07:00
Chris Chen 47aec287aa update mobile view for expense. 2026-06-23 13:49:38 -07:00
29 changed files with 569 additions and 118 deletions
@@ -197,6 +197,48 @@ public class ExpenseServiceTests
bobSvc.UpdateAsync(id, CloneToUpdate(Reimb()), isFinance: false)); bobSvc.UpdateAsync(id, CloneToUpdate(Reimb()), isFinance: false));
} }
[Fact]
public async Task Update_OwnPendingApproval_AsNonFinance_Succeeds()
{
// After Submit a reimbursement sits in PendingApproval; the owner may still correct it.
var (svc, db, _) = Build("alice");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
await svc.SubmitAsync(id);
Assert.Equal("PendingApproval", (await db.Expenses.FindAsync(id))!.Status);
var edit = CloneToUpdate(Reimb());
edit.Amount = 99.99m;
await svc.UpdateAsync(id, edit, isFinance: false);
var e = await db.Expenses.FindAsync(id);
Assert.Equal(99.99m, e!.Amount);
Assert.Equal("PendingApproval", e.Status);
}
[Fact]
public async Task Update_OwnApproved_AsNonFinance_Throws()
{
// Once approved, the owner can no longer edit.
var (svc, db, fs) = Build("alice");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
await svc.SubmitAsync(id);
await SvcAs(db, fs, "finance").ApproveAsync(id);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
svc.UpdateAsync(id, CloneToUpdate(Reimb()), isFinance: false));
}
[Fact]
public async Task SaveReceipt_OwnPendingApproval_AsNonFinance_Succeeds()
{
var (svc, db, _) = Build("alice");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
await svc.SubmitAsync(id);
using var input = new MemoryStream(Encoding.UTF8.GetBytes("img"));
await svc.SaveReceiptAsync(id, input, "r.jpg", isFinance: false);
Assert.NotNull(await svc.OpenReceiptAsync(id, isFinance: true));
}
[Fact] [Fact]
public async Task SoftDelete_HidesFromQueries() public async Task SoftDelete_HidesFromQueries()
{ {
+4 -4
View File
@@ -174,8 +174,8 @@ public class ExpenseService : IExpenseService
// FirstOrDefaultAsync (not FindAsync) so the soft-delete query filter applies. // FirstOrDefaultAsync (not FindAsync) so the soft-delete query filter applies.
var e = await _db.Expenses.FirstOrDefaultAsync(x => x.Id == id) var e = await _db.Expenses.FirstOrDefaultAsync(x => x.Id == id)
?? throw new KeyNotFoundException($"Expense {id} not found."); ?? throw new KeyNotFoundException($"Expense {id} not found.");
if (!isFinance && !(e.SubmittedBy == CurrentUserId && e.Status == "Draft")) if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval")))
throw new InvalidOperationException("You can only edit your own draft reimbursements."); throw new InvalidOperationException("You can only edit your own draft or pending reimbursements.");
e.MinistryId = r.MinistryId; e.CategoryGroupId = r.CategoryGroupId; e.SubCategoryId = r.SubCategoryId; e.MinistryId = r.MinistryId; e.CategoryGroupId = r.CategoryGroupId; e.SubCategoryId = r.SubCategoryId;
e.Amount = r.Amount; e.Description = r.Description; e.CheckNumber = r.CheckNumber; e.Amount = r.Amount; e.Description = r.Description; e.CheckNumber = r.CheckNumber;
@@ -245,8 +245,8 @@ public class ExpenseService : IExpenseService
public async Task SaveReceiptAsync(int id, Stream content, string fileName, bool isFinance) public async Task SaveReceiptAsync(int id, Stream content, string fileName, bool isFinance)
{ {
var e = await RequireAsync(id); var e = await RequireAsync(id);
if (!isFinance && e.SubmittedBy != CurrentUserId) if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval")))
throw new InvalidOperationException("You can only attach receipts to your own reimbursements."); throw new InvalidOperationException("You can only attach receipts to your own draft or pending reimbursements.");
var safe = Path.GetFileName(fileName).Replace(' ', '_'); var safe = Path.GetFileName(fileName).Replace(' ', '_');
var path = $"finance/receipts/{e.ExpenseDate.Year}/{e.ExpenseDate.Month}/{e.Id}-{safe}"; var path = $"finance/receipts/{e.ExpenseDate.Year}/{e.ExpenseDate.Month}/{e.Id}-{safe}";
@@ -1,4 +1,4 @@
<kendo-dialog title="Issue Checks / 開立支票" [width]="720" (close)="onClose()"> <kendo-dialog title="Issue Checks / 開立支票" [width]="720" [maxWidth]="'95vw'" [maxHeight]="'90vh'" (close)="onClose()">
<div class="p-2 flex flex-col gap-4" style="max-height: 70vh; overflow-y: auto;"> <div class="p-2 flex flex-col gap-4" style="max-height: 70vh; overflow-y: auto;">
<label class="flex flex-col gap-1 w-60"> <label class="flex flex-col gap-1 w-60">
@@ -1,4 +1,4 @@
<kendo-dialog title="Receipt Acknowledgement / 簽收" [width]="480" (close)="onClose()"> <kendo-dialog title="Receipt Acknowledgement / 簽收" [width]="'95vw'" [maxWidth]="480" (close)="onClose()">
<div class="p-2 flex flex-col gap-3"> <div class="p-2 flex flex-col gap-3">
<div class="text-sm" style="color:#374151;"> <div class="text-sm" style="color:#374151;">
Check #{{ check.checkNumber }} · {{ check.payeeName }} · {{ check.amount | currency }} Check #{{ check.checkNumber }} · {{ check.payeeName }} · {{ check.amount | currency }}
@@ -12,8 +12,8 @@
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<span class="text-sm">Signature / 簽名</span> <span class="text-sm">Signature / 簽名</span>
<canvas #pad width="440" height="180" <canvas #pad width="440" height="180"
class="border rounded touch-none" class="border rounded touch-none w-full"
style="border-color:#9ca3af; background:#fff; touch-action:none;" style="border-color:#9ca3af; background:#fff; touch-action:none; height:auto; aspect-ratio:440 / 180;"
(pointerdown)="onDown($event)" (pointerdown)="onDown($event)"
(pointermove)="onMove($event)" (pointermove)="onMove($event)"
(pointerup)="onUp()" (pointerup)="onUp()"
@@ -14,50 +14,107 @@
<button kendoButton (click)="applyFilter()">Apply</button> <button kendoButton (click)="applyFilter()">Apply</button>
</div> </div>
<kendo-grid [data]="{ data: rows, total: total }" [loading]="loading" [pageable]="true" [skip]="skip" <!-- Desktop / tablet: full data grid -->
[pageSize]="pageSize" (pageChange)="onPageChange($event)"> <div class="hidden md:block">
<kendo-grid [data]="{ data: rows, total: total }" [loading]="loading" [pageable]="true" [skip]="skip"
[pageSize]="pageSize" (pageChange)="onPageChange($event)">
<kendo-grid-column field="checkNumber" title="Check #" [width]="100"></kendo-grid-column> <kendo-grid-column field="checkNumber" title="Check #" [width]="100"></kendo-grid-column>
<kendo-grid-column field="checkDate" title="Date" [width]="110"></kendo-grid-column> <kendo-grid-column field="checkDate" title="Date" [width]="110"></kendo-grid-column>
<kendo-grid-column field="payeeName" title="Payee"></kendo-grid-column> <kendo-grid-column field="payeeName" title="Payee"></kendo-grid-column>
<kendo-grid-column field="amount" title="Amount" [width]="120" format="c2"></kendo-grid-column> <kendo-grid-column field="amount" title="Amount" [width]="120" format="c2"></kendo-grid-column>
<kendo-grid-column title="Lines" [width]="80"> <kendo-grid-column title="Lines" [width]="80">
<ng-template kendoGridCellTemplate let-dataItem>{{ dataItem.lineCount }}</ng-template> <ng-template kendoGridCellTemplate let-dataItem>{{ dataItem.lineCount }}</ng-template>
</kendo-grid-column> </kendo-grid-column>
<kendo-grid-column title="Status" [width]="110"> <kendo-grid-column title="Status" [width]="110">
<ng-template kendoGridCellTemplate let-dataItem> <ng-template kendoGridCellTemplate let-dataItem>
<span [class]="statusClass(dataItem.status)">{{ dataItem.status }}</span> <span [class]="statusClass(dataItem.status)">{{ dataItem.status }}</span>
</ng-template> </ng-template>
</kendo-grid-column> </kendo-grid-column>
<kendo-grid-column title="Receipt / 簽收" [width]="180"> <kendo-grid-column title="Receipt / 簽收" [width]="180">
<ng-template kendoGridCellTemplate let-dataItem> <ng-template kendoGridCellTemplate let-dataItem>
<ng-container *ngIf="dataItem.signed; else notSigned"> <ng-container *ngIf="dataItem.signed; else notSigned">
<span class="badge-paid">Signed</span> <span class="badge-paid">Signed</span>
<div class="text-xs" style="color:#6b7280;"> <div class="text-xs" style="color:#6b7280;">
{{ dataItem.receiptSignedName }} · {{ dataItem.receiptSignedAt | date:'short' }} {{ dataItem.receiptSignedName }} · {{ dataItem.receiptSignedAt | date:'short' }}
</div> </div>
</ng-container> </ng-container>
<ng-template #notSigned><span style="color:#9ca3af;"></span></ng-template> <ng-template #notSigned><span style="color:#9ca3af;"></span></ng-template>
</ng-template> </ng-template>
</kendo-grid-column> </kendo-grid-column>
<kendo-grid-column title="Actions" [width]="600"> <kendo-grid-column title="Actions" [width]="600">
<ng-template kendoGridCellTemplate let-dataItem> <ng-template kendoGridCellTemplate let-dataItem>
<button kendoButton fillMode="flat" (click)="view(dataItem)">View</button> <button kendoButton fillMode="flat" (click)="view(dataItem)">View</button>
<button kendoButton fillMode="flat" themeColor="primary" (click)="print(dataItem)">Print</button> <button kendoButton fillMode="flat" themeColor="primary" (click)="print(dataItem)">Print</button>
<button *ngIf="canSign(dataItem)" kendoButton fillMode="flat" themeColor="success" <button *ngIf="canSign(dataItem)" kendoButton fillMode="flat" themeColor="success"
(click)="openSign(dataItem)">簽收</button> (click)="openSign(dataItem)">簽收</button>
<!-- <button *ngIf="dataItem.signed" kendoButton fillMode="flat" (click)="viewSignature(dataItem)">Signature</button> --> <!-- <button *ngIf="dataItem.signed" kendoButton fillMode="flat" (click)="viewSignature(dataItem)">Signature</button> -->
<button *ngIf="dataItem.signed" kendoButton fillMode="flat" themeColor="primary" <button *ngIf="dataItem.signed" kendoButton fillMode="flat" themeColor="primary"
(click)="printReceipt(dataItem)">收據</button> (click)="printReceipt(dataItem)">收據</button>
<button *ngIf="canVoid(dataItem)" kendoButton fillMode="flat" themeColor="error" <button *ngIf="canVoid(dataItem)" kendoButton fillMode="flat" themeColor="error"
(click)="openVoid(dataItem)">Void</button> (click)="openVoid(dataItem)">Void</button>
</ng-template> </ng-template>
</kendo-grid-column> </kendo-grid-column>
</kendo-grid> </kendo-grid>
</div>
<!-- Mobile: tappable card list -->
<div class="md:hidden chk-cards">
<div *ngIf="loading" class="chk-empty">Loading…</div>
<div *ngIf="!loading && rows.length === 0" class="chk-empty">No checks found.</div>
<div class="chk-card" *ngFor="let row of rows">
<div class="chk-card__top">
<span class="chk-card__number">Check #{{ row.checkNumber }}</span>
<span class="chk-card__amount">{{ row.amount | currency:'USD':'symbol':'1.2-2' }}</span>
</div>
<div class="chk-card__payee">{{ row.payeeName }}</div>
<dl class="chk-card__meta">
<div>
<dt>Date</dt>
<dd>{{ row.checkDate }}</dd>
</div>
<div>
<dt>Lines</dt>
<dd>{{ row.lineCount }}</dd>
</div>
<div>
<dt>Receipt / 簽收</dt>
<dd>
<ng-container *ngIf="row.signed; else notSignedCard">
{{ row.receiptSignedName }} · {{ row.receiptSignedAt | date:'short' }}
</ng-container>
<ng-template #notSignedCard></ng-template>
</dd>
</div>
</dl>
<div class="chk-card__footer">
<span [class]="statusClass(row.status)">{{ row.status }}</span>
<span *ngIf="row.signed" class="badge-paid">Signed</span>
</div>
<div class="chk-card__actions">
<button kendoButton fillMode="outline" (click)="view(row)">View</button>
<button kendoButton themeColor="primary" (click)="print(row)">Print</button>
<button *ngIf="canSign(row)" kendoButton themeColor="success" (click)="openSign(row)">簽收</button>
<button *ngIf="row.signed" kendoButton fillMode="outline" (click)="printReceipt(row)">收據</button>
<button *ngIf="canVoid(row)" kendoButton themeColor="error" fillMode="outline" (click)="openVoid(row)">Void</button>
</div>
</div>
<div class="chk-pager" *ngIf="!loading && rows.length > 0">
<button kendoButton fillMode="outline" [disabled]="page <= 1" (click)="prevPage()">Prev</button>
<span class="chk-pager__info">Page {{ page }} of {{ totalPages }}</span>
<button kendoButton fillMode="outline" [disabled]="page >= totalPages" (click)="nextPage()">Next</button>
</div>
</div>
<!-- Detail dialog --> <!-- Detail dialog -->
<kendo-dialog *ngIf="detail" title="Check #{{ detail.checkNumber }}" [width]="560" (close)="detail = null"> <kendo-dialog *ngIf="detail" title="Check #{{ detail.checkNumber }}" [width]="560" [maxWidth]="'95vw'" [maxHeight]="'90vh'" (close)="detail = null">
<div class="p-2 flex flex-col gap-2"> <div class="p-2 flex flex-col gap-2">
<div class="grid grid-cols-2 gap-2 text-sm"> <div class="grid grid-cols-2 gap-2 text-sm">
<div><strong>Payee:</strong> {{ detail.payeeName }}</div> <div><strong>Payee:</strong> {{ detail.payeeName }}</div>
@@ -93,7 +150,7 @@
</kendo-dialog> </kendo-dialog>
<!-- Void dialog --> <!-- Void dialog -->
<kendo-dialog *ngIf="voidRow" title="Void Check #{{ voidRow.checkNumber }}" [width]="420" (close)="voidRow = null"> <kendo-dialog *ngIf="voidRow" title="Void Check #{{ voidRow.checkNumber }}" [width]="420" [maxWidth]="'95vw'" (close)="voidRow = null">
<div class="p-2 flex flex-col gap-2"> <div class="p-2 flex flex-col gap-2">
<p class="text-sm" style="color:#991b1b;"> <p class="text-sm" style="color:#991b1b;">
Voiding returns the bundled expenses to Approved so they can be re-issued. Voiding returns the bundled expenses to Approved so they can be re-issued.
@@ -24,3 +24,112 @@
background-color: #fee2e2; background-color: #fee2e2;
color: #991b1b; color: #991b1b;
} }
// Mobile card list
// NOTE: display/flex layout lives on the element via Tailwind (flex flex-col gap-3)
// so the responsive `md:hidden` utility wins on desktop. Setting `display: flex`
// here would override `md:hidden` and leak the card list onto the desktop view.
.chk-empty {
padding: 24px 0;
text-align: center;
color: #6b7280;
}
.chk-card {
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #fff;
padding: 14px 16px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
&__top {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
&__number {
font-size: 0.85rem;
font-weight: 600;
color: #374151;
}
&__amount {
font-size: 1.1rem;
font-weight: 700;
color: #111827;
}
&__payee {
margin-top: 4px;
font-weight: 600;
color: #1f2937;
}
&__meta {
margin: 10px 0 0;
display: flex;
flex-direction: column;
gap: 6px;
div {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 0.85rem;
}
dt {
margin: 0;
color: #6b7280;
}
dd {
margin: 0;
color: #374151;
text-align: right;
}
}
&__footer {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
&__actions {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #f3f4f6;
display: flex;
flex-wrap: wrap;
gap: 8px;
// Comfortable tap targets; let buttons share the row evenly
.k-button {
flex: 1 1 auto;
min-height: 40px;
justify-content: center;
}
}
}
.chk-pager {
margin-top: 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
&__info {
font-size: 0.85rem;
color: #6b7280;
}
.k-button {
min-height: 40px;
}
}
@@ -49,9 +49,24 @@ export class CheckRegisterPageComponent implements OnInit {
} }
get skip(): number { return (this.page - 1) * this.pageSize; } get skip(): number { return (this.page - 1) * this.pageSize; }
get totalPages(): number { return Math.max(1, Math.ceil(this.total / this.pageSize)); }
applyFilter(): void { this.page = 1; this.load(); } applyFilter(): void { this.page = 1; this.load(); }
onPageChange(e: PageChangeEvent): void { this.page = Math.floor(e.skip / this.pageSize) + 1; this.load(); } onPageChange(e: PageChangeEvent): void { this.page = Math.floor(e.skip / this.pageSize) + 1; this.load(); }
prevPage(): void {
if (this.page > 1) {
this.page--;
this.load();
}
}
nextPage(): void {
if (this.page < this.totalPages) {
this.page++;
this.load();
}
}
view(row: CheckListItemDto): void { view(row: CheckListItemDto): void {
this.api.getCheck(row.id).subscribe(d => (this.detail = d)); this.api.getCheck(row.id).subscribe(d => (this.detail = d));
} }
@@ -1,6 +1,12 @@
<kendo-dialog [title]="title" (close)="cancel.emit()" [width]="560"> <kendo-dialog [title]="title" (close)="cancel.emit()" [width]="560" [maxWidth]="'95vw'" [maxHeight]="'90vh'">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3"> <div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<!-- Continuous entry: keep member/ministry/category/date after each save (on-behalf reimbursement only) -->
<label *ngIf="showContinueEntry" class="flex items-center gap-2 md:col-span-2">
<kendo-switch [(ngModel)]="continueEntry"></kendo-switch>
<span>連續登打 / Continuous Entry</span>
</label>
<!-- Member picker (finance creating on behalf of a member) --> <!-- Member picker (finance creating on behalf of a member) -->
<label *ngIf="allowMemberPick" class="flex flex-col gap-1 md:col-span-2">Member <label *ngIf="allowMemberPick" class="flex flex-col gap-1 md:col-span-2">Member
<kendo-dropdownlist <kendo-dropdownlist
@@ -91,6 +97,7 @@
@Output() cancel and wrongly close the dialog. See Angular issues #50556 / #13997. @Output() cancel and wrongly close the dialog. See Angular issues #50556 / #13997.
--> -->
<input <input
#receiptInput
type="file" type="file"
accept="image/*,application/pdf" accept="image/*,application/pdf"
(change)="onFileSelected($event)" (change)="onFileSelected($event)"
@@ -1,4 +1,4 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { InputsModule } from '@progress/kendo-angular-inputs'; import { InputsModule } from '@progress/kendo-angular-inputs';
@@ -15,7 +15,12 @@ import {
ExpenseListItemDto, ExpenseListItemDto,
} from '../../models/expense.model'; } from '../../models/expense.model';
export interface ExpenseFormResult { request: CreateExpenseRequest; receipt: File | null; } export interface ExpenseFormResult {
request: CreateExpenseRequest;
receipt: File | null;
/** When true (continuous-entry mode), the parent should keep the dialog open after saving. */
continueEntry: boolean;
}
/** Flattened member item with a single displayName field for the dropdown. */ /** Flattened member item with a single displayName field for the dropdown. */
interface MemberOption { id: number; displayName: string; } interface MemberOption { id: number; displayName: string; }
@@ -35,12 +40,23 @@ export class ExpenseFormDialogComponent implements OnInit {
@Output() save = new EventEmitter<ExpenseFormResult>(); @Output() save = new EventEmitter<ExpenseFormResult>();
@Output() cancel = new EventEmitter<void>(); @Output() cancel = new EventEmitter<void>();
/** Native receipt file input, cleared between continuous-entry saves. */
@ViewChild('receiptInput') receiptInput?: ElementRef<HTMLInputElement>;
ministries: MinistryDto[] = []; ministries: MinistryDto[] = [];
groups: ExpenseCategoryGroupDto[] = []; groups: ExpenseCategoryGroupDto[] = [];
subs: ExpenseSubCategoryDto[] = []; subs: ExpenseSubCategoryDto[] = [];
memberResults: MemberOption[] = []; memberResults: MemberOption[] = [];
/** Continuous-entry toggle: keep member/ministry/category/date and the dialog open after each save. */
continueEntry = false;
/** The on-behalf reimbursement create flow is the only place continuous entry applies. */
get showContinueEntry(): boolean {
return this.mode === 'reimbursement' && this.allowMemberPick && !this.expense;
}
form = { form = {
ministryId: null as number | null, ministryId: null as number | null,
categoryGroupId: null as number | null, categoryGroupId: null as number | null,
@@ -131,6 +147,21 @@ export class ExpenseFormDialogComponent implements OnInit {
expenseDate, expenseDate,
notes: null, notes: null,
}; };
this.save.emit({ request, receipt: this.receipt }); // The request and receipt are snapshotted here, so resetting the form right
// after emitting is safe even though the parent saves asynchronously.
this.save.emit({ request, receipt: this.receipt, continueEntry: this.continueEntry });
if (this.continueEntry) this.resetForNext();
}
/**
* Clear only the per-entry fields, keeping Member, Ministry, Category Group,
* Sub-Category and Expense Date (plus the loaded sub-category list) so the
* user can immediately log the next reimbursement.
*/
private resetForNext(): void {
this.form.amount = 0;
this.form.description = '';
this.receipt = null;
if (this.receiptInput) this.receiptInput.nativeElement.value = '';
} }
} }
@@ -47,7 +47,7 @@
<kendo-dialog *ngIf="groupDialogOpen" <kendo-dialog *ngIf="groupDialogOpen"
[title]="editingGroupId != null ? 'Edit Group' : 'New Group'" [title]="editingGroupId != null ? 'Edit Group' : 'New Group'"
(close)="groupDialogOpen = false" (close)="groupDialogOpen = false"
[width]="480"> [width]="480" [maxWidth]="'95vw'">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3"> <div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
Name (EN) * Name (EN) *
@@ -75,7 +75,7 @@
<kendo-dialog *ngIf="subDialogOpen" <kendo-dialog *ngIf="subDialogOpen"
[title]="editingSubId != null ? 'Edit Subcategory' : 'New Subcategory'" [title]="editingSubId != null ? 'Edit Subcategory' : 'New Subcategory'"
(close)="subDialogOpen = false" (close)="subDialogOpen = false"
[width]="480"> [width]="480" [maxWidth]="'95vw'">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3"> <div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
Name (EN) * Name (EN) *
@@ -68,8 +68,9 @@
</ng-template> </ng-template>
</kendo-grid-column> </kendo-grid-column>
<kendo-grid-column title="Actions" [width]="100"> <kendo-grid-column title="Actions" [width]="160">
<ng-template kendoGridCellTemplate let-dataItem> <ng-template kendoGridCellTemplate let-dataItem>
<button *ngIf="canEdit(dataItem)" kendoButton fillMode="flat" (click)="openEdit(dataItem)">Edit</button>
<ng-container *ngIf="canApproveOrReject(dataItem)"> <ng-container *ngIf="canApproveOrReject(dataItem)">
<button kendoButton themeColor="success" fillMode="flat" (click)="approve(dataItem)">Approve</button> <button kendoButton themeColor="success" fillMode="flat" (click)="approve(dataItem)">Approve</button>
<button kendoButton themeColor="error" fillMode="flat" (click)="openReject(dataItem)">Reject</button> <button kendoButton themeColor="error" fillMode="flat" (click)="openReject(dataItem)">Reject</button>
@@ -93,8 +94,14 @@
title="Reimbursement (on behalf)" (save)="onReimbSave($event)" (cancel)="reimbDialogOpen = false"> title="Reimbursement (on behalf)" (save)="onReimbSave($event)" (cancel)="reimbDialogOpen = false">
</app-expense-form-dialog> </app-expense-form-dialog>
<!-- Edit dialog -->
<app-expense-form-dialog *ngIf="editRow" [mode]="editMode" [expense]="editRow"
[title]="editMode === 'vendor' ? 'Edit Vendor Payment' : 'Edit Reimbursement'"
(save)="onEditSave($event)" (cancel)="closeEdit()">
</app-expense-form-dialog>
<!-- Mark Paid dialog --> <!-- Mark Paid dialog -->
<kendo-dialog *ngIf="payRow" title="Mark Paid" [width]="400" (close)="payRow = null"> <kendo-dialog *ngIf="payRow" title="Mark Paid" [width]="400" [maxWidth]="'95vw'" (close)="payRow = null">
<div class="grid grid-cols-1 gap-3 p-2"> <div class="grid grid-cols-1 gap-3 p-2">
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
Check # Check #
@@ -112,7 +119,7 @@
</kendo-dialog> </kendo-dialog>
<!-- Reject dialog --> <!-- Reject dialog -->
<kendo-dialog *ngIf="rejectRow" title="Reject Expense" [width]="400" (close)="rejectRow = null"> <kendo-dialog *ngIf="rejectRow" title="Reject Expense" [width]="400" [maxWidth]="'95vw'" (close)="rejectRow = null">
<div class="grid grid-cols-1 gap-3 p-2"> <div class="grid grid-cols-1 gap-3 p-2">
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
Review Notes Review Notes
@@ -125,4 +132,7 @@
</kendo-dialog-actions> </kendo-dialog-actions>
</kendo-dialog> </kendo-dialog>
<!-- Transient save confirmation (sits above the open dialog during continuous entry) -->
<div *ngIf="toast" class="save-toast">{{ toast }}</div>
</div> </div>
@@ -44,3 +44,20 @@
color: #1d4ed8; color: #1d4ed8;
text-decoration: underline; text-decoration: underline;
} }
// Save confirmation pill. z-index sits above the Kendo dialog overlay so it
// stays visible while the continuous-entry dialog remains open.
.save-toast {
position: fixed;
left: 50%;
bottom: 2rem;
transform: translateX(-50%);
z-index: 20000;
padding: 0.7rem 1.2rem;
border-radius: 9999px;
font-size: 0.95rem;
font-weight: 600;
color: #f0fdf4;
background: #16a34a;
box-shadow: 0 12px 30px -12px rgba(22, 163, 74, 0.7);
}
@@ -39,6 +39,9 @@ export class ExpensesPageComponent implements OnInit {
vendorDialogOpen = false; vendorDialogOpen = false;
reimbDialogOpen = false; reimbDialogOpen = false;
editRow: ExpenseListItemDto | null = null;
editMode: 'vendor' | 'reimbursement' = 'reimbursement';
payRow: ExpenseListItemDto | null = null; payRow: ExpenseListItemDto | null = null;
payCheckNumber = ''; payCheckNumber = '';
payDate = new Date(); payDate = new Date();
@@ -46,6 +49,10 @@ export class ExpensesPageComponent implements OnInit {
rejectRow: ExpenseListItemDto | null = null; rejectRow: ExpenseListItemDto | null = null;
rejectNotes = ''; rejectNotes = '';
/** Transient confirmation pill, used so the user gets feedback during continuous entry. */
toast: string | null = null;
private toastTimer?: ReturnType<typeof setTimeout>;
constructor(private api: ExpenseApiService, private ministryApi: MinistryApiService) { } constructor(private api: ExpenseApiService, private ministryApi: MinistryApiService) { }
ngOnInit(): void { ngOnInit(): void {
@@ -79,7 +86,27 @@ export class ExpensesPageComponent implements OnInit {
switchMap(c => result.receipt switchMap(c => result.receipt
? this.api.uploadReceipt(c.id, result.receipt).pipe(switchMap(() => of(c))) ? this.api.uploadReceipt(c.id, result.receipt).pipe(switchMap(() => of(c)))
: of(c)), : of(c)),
).subscribe(() => { this.reimbDialogOpen = false; this.load(); }); ).subscribe(() => {
// In continuous-entry mode the dialog resets itself and stays open for the next entry.
if (!result.continueEntry) this.reimbDialogOpen = false;
this.showToast('已儲存 ✓ Saved');
this.load();
});
}
openEdit(row: ExpenseListItemDto): void {
this.editRow = row;
this.editMode = row.type === 'VendorPayment' ? 'vendor' : 'reimbursement';
}
closeEdit(): void { this.editRow = null; }
onEditSave(result: ExpenseFormResult): void {
if (!this.editRow) return;
const id = this.editRow.id;
this.api.update(id, result.request).pipe(
switchMap(() => result.receipt ? this.api.uploadReceipt(id, result.receipt) : of(void 0)),
).subscribe(() => { this.closeEdit(); this.load(); });
} }
approve(row: ExpenseListItemDto): void { approve(row: ExpenseListItemDto): void {
@@ -123,6 +150,8 @@ export class ExpensesPageComponent implements OnInit {
}); });
} }
/** Finance may edit (and reupload the receipt) while the expense is still Draft or awaiting review. */
canEdit(row: ExpenseListItemDto): boolean { return row.status === 'Draft' || row.status === 'PendingApproval'; }
canApproveOrReject(row: ExpenseListItemDto): boolean { return row.status === 'PendingApproval'; } canApproveOrReject(row: ExpenseListItemDto): boolean { return row.status === 'PendingApproval'; }
canPay(row: ExpenseListItemDto): boolean { canPay(row: ExpenseListItemDto): boolean {
return false; return false;
@@ -130,6 +159,12 @@ export class ExpensesPageComponent implements OnInit {
//should be pay by disbursement //should be pay by disbursement
} }
private showToast(message: string): void {
this.toast = message;
if (this.toastTimer) clearTimeout(this.toastTimer);
this.toastTimer = setTimeout(() => (this.toast = null), 2200);
}
statusClass(status: string): string { statusClass(status: string): string {
return ({ return ({
Draft: 'badge-draft', Draft: 'badge-draft',
@@ -55,7 +55,7 @@
</kendo-grid> </kendo-grid>
<!-- Create / Edit dialog --> <!-- Create / Edit dialog -->
<kendo-dialog *ngIf="dialogOpen" title="Monthly Statement" [width]="560" (close)="dialogOpen = false"> <kendo-dialog *ngIf="dialogOpen" title="Monthly Statement" [width]="560" [maxWidth]="'95vw'" [maxHeight]="'90vh'" (close)="dialogOpen = false">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 p-2"> <div class="grid grid-cols-1 md:grid-cols-2 gap-3 p-2">
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
@@ -3,33 +3,71 @@
<button kendoButton themeColor="primary" (click)="openNew()">+ New Reimbursement</button> <button kendoButton themeColor="primary" (click)="openNew()">+ New Reimbursement</button>
</ng-template> </ng-template>
<kendo-grid [data]="rows" [loading]="loading"> <!-- Desktop / tablet: full data grid -->
<kendo-grid-column field="expenseDate" title="Date" [width]="110"></kendo-grid-column> <div class="hidden md:block">
<kendo-grid-column field="description" title="Description"></kendo-grid-column> <kendo-grid [data]="rows" [loading]="loading">
<kendo-grid-column field="ministryName" title="Ministry" [width]="140"></kendo-grid-column> <kendo-grid-column field="expenseDate" title="Date" [width]="110"></kendo-grid-column>
<kendo-grid-column title="Category"> <kendo-grid-column field="description" title="Description"></kendo-grid-column>
<ng-template kendoGridCellTemplate let-dataItem> <kendo-grid-column field="ministryName" title="Ministry" [width]="140"></kendo-grid-column>
{{ dataItem.categoryGroupName }} / {{ dataItem.subCategoryName }} <kendo-grid-column title="Category">
</ng-template> <ng-template kendoGridCellTemplate let-dataItem>
</kendo-grid-column> {{ dataItem.categoryGroupName }} / {{ dataItem.subCategoryName }}
<kendo-grid-column field="amount" title="Amount" [width]="110" format="c2"></kendo-grid-column> </ng-template>
<kendo-grid-column title="Status" [width]="140"> </kendo-grid-column>
<ng-template kendoGridCellTemplate let-dataItem> <kendo-grid-column field="amount" title="Amount" [width]="110" format="c2"></kendo-grid-column>
<span [class]="statusClass(dataItem.status)">{{ dataItem.status }}</span> <kendo-grid-column title="Status" [width]="140">
</ng-template> <ng-template kendoGridCellTemplate let-dataItem>
</kendo-grid-column> <span [class]="statusClass(dataItem.status)">{{ dataItem.status }}</span>
<kendo-grid-column title="Actions" [width]="200"> </ng-template>
<ng-template kendoGridCellTemplate let-dataItem> </kendo-grid-column>
<ng-container *ngIf="canEdit(dataItem)"> <kendo-grid-column title="Actions" [width]="200">
<button kendoButton fillMode="flat" (click)="openEdit(dataItem)">Edit</button> <ng-template kendoGridCellTemplate let-dataItem>
<button kendoButton themeColor="primary" fillMode="flat" (click)="submit(dataItem)">Submit</button> <button *ngIf="canEdit(dataItem)" kendoButton fillMode="flat" (click)="openEdit(dataItem)">Edit</button>
<button kendoButton fillMode="flat" (click)="remove(dataItem)">Delete</button> <button *ngIf="isDraft(dataItem)" kendoButton themeColor="primary" fillMode="flat" (click)="submit(dataItem)">Submit</button>
</ng-container> <button *ngIf="isDraft(dataItem)" kendoButton fillMode="flat" (click)="remove(dataItem)">Delete</button>
<button *ngIf="dataItem.hasReceipt" kendoButton fillMode="flat" <button *ngIf="dataItem.hasReceipt" kendoButton fillMode="flat"
(click)="openReceipt(dataItem.id)" class="receipt-link">Receipt</button> (click)="openReceipt(dataItem.id)" class="receipt-link">Receipt</button>
</ng-template> </ng-template>
</kendo-grid-column> </kendo-grid-column>
</kendo-grid> </kendo-grid>
</div>
<!-- Mobile: tappable card list -->
<div class="md:hidden flex flex-col gap-3 rmb-cards">
<div *ngIf="loading" class="rmb-empty">Loading…</div>
<div *ngIf="!loading && rows.length === 0" class="rmb-empty">No reimbursements yet.</div>
<div class="rmb-card" *ngFor="let row of rows">
<div class="rmb-card__top">
<span class="rmb-card__date">{{ row.expenseDate }}</span>
<span class="rmb-card__amount">{{ row.amount | currency:'USD':'symbol':'1.2-2' }}</span>
</div>
<div class="rmb-card__desc">{{ row.description }}</div>
<dl class="rmb-card__meta">
<div>
<dt>Ministry</dt>
<dd>{{ row.ministryName }}</dd>
</div>
<div>
<dt>Category</dt>
<dd>{{ row.categoryGroupName }} / {{ row.subCategoryName }}</dd>
</div>
</dl>
<div class="rmb-card__footer">
<span [class]="statusClass(row.status)">{{ row.status }}</span>
</div>
<div class="rmb-card__actions" *ngIf="canEdit(row) || row.hasReceipt">
<button *ngIf="canEdit(row)" kendoButton fillMode="outline" (click)="openEdit(row)">Edit</button>
<button *ngIf="isDraft(row)" kendoButton themeColor="primary" (click)="submit(row)">Submit</button>
<button *ngIf="isDraft(row)" kendoButton fillMode="outline" (click)="remove(row)">Delete</button>
<button *ngIf="row.hasReceipt" kendoButton fillMode="flat" (click)="openReceipt(row.id)">Receipt</button>
</div>
</div>
</div>
<app-expense-form-dialog <app-expense-form-dialog
*ngIf="dialogOpen" *ngIf="dialogOpen"
@@ -44,3 +44,91 @@
color: #1d4ed8; color: #1d4ed8;
text-decoration: underline; text-decoration: underline;
} }
// Mobile card list
// NOTE: display/flex layout lives on the element via Tailwind (flex flex-col gap-3)
// so the responsive `md:hidden` utility wins on desktop. Setting `display: flex`
// here would override `md:hidden` and leak the card list onto the desktop view.
.rmb-empty {
padding: 24px 0;
text-align: center;
color: #6b7280;
}
.rmb-card {
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #fff;
padding: 14px 16px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
&__top {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
&__date {
font-size: 0.8rem;
color: #6b7280;
}
&__amount {
font-size: 1.1rem;
font-weight: 700;
color: #111827;
}
&__desc {
margin-top: 4px;
font-weight: 600;
color: #1f2937;
}
&__meta {
margin: 10px 0 0;
display: flex;
flex-direction: column;
gap: 6px;
div {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 0.85rem;
}
dt {
margin: 0;
color: #6b7280;
}
dd {
margin: 0;
color: #374151;
text-align: right;
}
}
&__footer {
margin-top: 12px;
}
&__actions {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #f3f4f6;
display: flex;
flex-wrap: wrap;
gap: 8px;
// Comfortable tap targets; let buttons share the row evenly
.k-button {
flex: 1 1 auto;
min-height: 40px;
justify-content: center;
}
}
}
@@ -66,7 +66,10 @@ export class MyReimbursementsPageComponent implements OnInit {
}); });
} }
canEdit(row: ExpenseListItemDto): boolean { return row.status === 'Draft'; } /** Editing (and reuploading the photo) is allowed while a reimbursement is still Draft or awaiting review. */
canEdit(row: ExpenseListItemDto): boolean { return row.status === 'Draft' || row.status === 'PendingApproval'; }
/** Submit and Delete only apply before the reimbursement has been submitted. */
isDraft(row: ExpenseListItemDto): boolean { return row.status === 'Draft'; }
statusClass(status: string): string { statusClass(status: string): string {
return ({ Draft: 'badge-draft', PendingApproval: 'badge-pending', Approved: 'badge-approved', Paid: 'badge-paid', Rejected: 'badge-rejected' } as Record<string, string>)[status] ?? ''; return ({ Draft: 'badge-draft', PendingApproval: 'badge-pending', Approved: 'badge-approved', Paid: 'badge-paid', Rejected: 'badge-rejected' } as Record<string, string>)[status] ?? '';
} }
@@ -1,4 +1,4 @@
<kendo-dialog title="Quick add member" (close)="cancelled.emit()" [width]="420"> <kendo-dialog title="Quick add member" (close)="cancelled.emit()" [width]="420" [maxWidth]="'95vw'">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3"> <div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label class="flex flex-col gap-1">First name (EN) *<kendo-textbox [(ngModel)]="firstName_en"></kendo-textbox></label> <label class="flex flex-col gap-1">First name (EN) *<kendo-textbox [(ngModel)]="firstName_en"></kendo-textbox></label>
<label class="flex flex-col gap-1">Last name (EN) *<kendo-textbox [(ngModel)]="lastName_en"></kendo-textbox></label> <label class="flex flex-col gap-1">Last name (EN) *<kendo-textbox [(ngModel)]="lastName_en"></kendo-textbox></label>
@@ -21,7 +21,7 @@
</kendo-grid-column> </kendo-grid-column>
</kendo-grid> </kendo-grid>
<kendo-dialog *ngIf="showDialog" [title]="editing ? 'Edit Giving Type' : 'Add Giving Type'" (close)="showDialog=false" [width]="480"> <kendo-dialog *ngIf="showDialog" [title]="editing ? 'Edit Giving Type' : 'Add Giving Type'" (close)="showDialog=false" [width]="480" [maxWidth]="'95vw'">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3"> <div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
Name (EN) * Name (EN) *
@@ -28,7 +28,7 @@
</kendo-grid-column> </kendo-grid-column>
</kendo-grid> </kendo-grid>
<kendo-dialog *ngIf="showDialog" title="Add Giving" (close)="showDialog=false" [width]="520"> <kendo-dialog *ngIf="showDialog" title="Add Giving" (close)="showDialog=false" [width]="520" [maxWidth]="'95vw'" [maxHeight]="'90vh'">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3"> <div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label class="flex items-center gap-2 md:col-span-2"> <label class="flex items-center gap-2 md:col-span-2">
<input type="checkbox" [ngModel]="form.isAnonymous" (ngModelChange)="toggleAnonymous()" /> Anonymous <input type="checkbox" [ngModel]="form.isAnonymous" (ngModelChange)="toggleAnonymous()" /> Anonymous
@@ -108,7 +108,7 @@
<!-- Quick-add member --> <!-- Quick-add member -->
<kendo-dialog *ngIf="showQuickAdd" title="快速新增會友 · Quick add member" <kendo-dialog *ngIf="showQuickAdd" title="快速新增會友 · Quick add member"
(close)="cancelQuickAdd()" [minWidth]="280" [width]="360"> (close)="cancelQuickAdd()" [minWidth]="280" [width]="'95vw'" [maxWidth]="360">
<div class="oe__qa"> <div class="oe__qa">
<div class="oe__field"> <div class="oe__field">
<label class="oe__label">英文名 · Legal first name *</label> <label class="oe__label">英文名 · Legal first name *</label>
@@ -145,7 +145,7 @@
<!-- Today's totals: payment-method breakdown + per-check detail --> <!-- Today's totals: payment-method breakdown + per-check detail -->
<kendo-dialog *ngIf="showTotals" title="今日總計 · Today's Totals" <kendo-dialog *ngIf="showTotals" title="今日總計 · Today's Totals"
(close)="closeTotals()" [minWidth]="280" [width]="360"> (close)="closeTotals()" [minWidth]="280" [width]="'95vw'" [maxWidth]="360">
<div class="oe__qa"> <div class="oe__qa">
<p *ngIf="totalsLoading" class="oe__totals-loading">載入中… · Loading</p> <p *ngIf="totalsLoading" class="oe__totals-loading">載入中… · Loading</p>
@@ -192,7 +192,7 @@
<!-- Add paper proof: capture photos / pick files → compress + merge to one PDF --> <!-- Add paper proof: capture photos / pick files → compress + merge to one PDF -->
<kendo-dialog *ngIf="showPaperProof" title="新增 Paper Proof · 紙本證明" <kendo-dialog *ngIf="showPaperProof" title="新增 Paper Proof · 紙本證明"
(close)="cancelPaperProof()" [minWidth]="280" [width]="360"> (close)="cancelPaperProof()" [minWidth]="280" [width]="'95vw'" [maxWidth]="360">
<div class="oe__qa"> <div class="oe__qa">
<p class="oe__proof-hint">附上點算單/信封的照片或 PDF · Photo or PDF of the count sheet / envelopes</p> <p class="oe__proof-hint">附上點算單/信封的照片或 PDF · Photo or PDF of the count sheet / envelopes</p>
@@ -293,7 +293,7 @@
</ng-container> </ng-container>
<!-- Reopen confirm dialog --> <!-- Reopen confirm dialog -->
<kendo-dialog *ngIf="confirmReopenOpen" title="Reopen session? / 重新開啟" (close)="confirmReopenOpen = false" [width]="440"> <kendo-dialog *ngIf="confirmReopenOpen" title="Reopen session? / 重新開啟" (close)="confirmReopenOpen = false" [width]="440" [maxWidth]="'95vw'">
<p class="dialog-text"> <p class="dialog-text">
Editing a submitted session will reopen it and set its status back to <strong>Draft</strong> until you submit again. Editing a submitted session will reopen it and set its status back to <strong>Draft</strong> until you submit again.
<br><span>編輯已送出的 session 會重新開啟並將狀態改回草稿,直到再次送出。</span> <br><span>編輯已送出的 session 會重新開啟並將狀態改回草稿,直到再次送出。</span>
@@ -58,7 +58,7 @@
</kendo-grid> </kendo-grid>
<!-- Detail dialog --> <!-- Detail dialog -->
<kendo-dialog *ngIf="detail" title="Audit Log #{{ detail.id }}" [width]="720" (close)="detail = null"> <kendo-dialog *ngIf="detail" title="Audit Log #{{ detail.id }}" [width]="720" [maxWidth]="'95vw'" [maxHeight]="'90vh'" (close)="detail = null">
<div class="p-2 flex flex-col gap-2 text-sm"> <div class="p-2 flex flex-col gap-2 text-sm">
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
<div><strong>Time:</strong> {{ detail.timestamp | date:'medium' }}</div> <div><strong>Time:</strong> {{ detail.timestamp | date:'medium' }}</div>
@@ -49,7 +49,7 @@
</kendo-grid> </kendo-grid>
<!-- Detail dialog --> <!-- Detail dialog -->
<kendo-dialog *ngIf="detail" title="System Log #{{ detail.id }}" [width]="720" (close)="detail = null"> <kendo-dialog *ngIf="detail" title="System Log #{{ detail.id }}" [width]="720" [maxWidth]="'95vw'" [maxHeight]="'90vh'" (close)="detail = null">
<div class="p-2 flex flex-col gap-2 text-sm"> <div class="p-2 flex flex-col gap-2 text-sm">
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
<div><strong>Time:</strong> {{ detail.timestamp | date:'medium' }}</div> <div><strong>Time:</strong> {{ detail.timestamp | date:'medium' }}</div>
@@ -1,4 +1,4 @@
<kendo-dialog title="Create User Account" (close)="onCancel()" [minWidth]="480" [width]="520"> <kendo-dialog title="Create User Account" (close)="onCancel()" [width]="520" [maxWidth]="'95vw'" [maxHeight]="'90vh'">
<!-- STEP 1: Form --> <!-- STEP 1: Form -->
<ng-container *ngIf="step === 'form'"> <ng-container *ngIf="step === 'form'">
@@ -1,4 +1,4 @@
<kendo-dialog [title]="title" (close)="onCancel()" [minWidth]="600" [width]="750"> <kendo-dialog [title]="title" (close)="onCancel()" [width]="750" [maxWidth]="'95vw'" [maxHeight]="'90vh'">
<form [formGroup]="form" (ngSubmit)="onSubmit()"> <form [formGroup]="form" (ngSubmit)="onSubmit()">
<kendo-tabstrip> <kendo-tabstrip>
@@ -1,4 +1,4 @@
<kendo-dialog title="Add New User" (close)="onCancel()" [minWidth]="460" [width]="500"> <kendo-dialog title="Add New User" (close)="onCancel()" [width]="500" [maxWidth]="'95vw'" [maxHeight]="'90vh'">
<form [formGroup]="form" class="k-form k-form-vertical k-p-2"> <form [formGroup]="form" class="k-form k-form-vertical k-p-2">
<div class="grid grid-cols-1 gap-y-3"> <div class="grid grid-cols-1 gap-y-3">
@@ -1,4 +1,4 @@
<kendo-dialog title="Edit User" (close)="onCancel()" [minWidth]="460" [width]="500"> <kendo-dialog title="Edit User" (close)="onCancel()" [width]="500" [maxWidth]="'95vw'" [maxHeight]="'90vh'">
<form [formGroup]="form" class="k-form k-form-vertical k-p-2"> <form [formGroup]="form" class="k-form k-form-vertical k-p-2">
<div class="grid grid-cols-1 gap-y-3"> <div class="grid grid-cols-1 gap-y-3">
@@ -336,10 +336,17 @@
transition: all 0.2s ease; transition: all 0.2s ease;
position: relative; position: relative;
margin: 0.125rem 0; margin: 0.125rem 0;
// Suppress the grey native tap-flash on touch devices; the active state below
// is the intended feedback.
-webkit-tap-highlight-color: transparent;
&:hover { // Only apply hover on devices that can truly hover (desktop). On touch, hover
background: rgba(30, 64, 175, 0.1); // styles "stick" after a tap and leave a muddy box on the last-tapped item.
color: #1e40af; @media (hover: hover) {
&:hover {
background: rgba(30, 64, 175, 0.1);
color: #1e40af;
}
} }
&.active { &.active {
@@ -612,6 +619,12 @@
display: block; display: block;
} }
// The top-header hamburger toggles the drawer on mobile, so the duplicate
// toggle inside the open drawer header is redundant — hide it.
.sidebar-toggle {
display: none;
}
.page-content { .page-content {
padding: 1rem; padding: 1rem;
} }
@@ -635,20 +648,6 @@
} }
} }
// Overlay for mobile sidebar
@media (max-width: 768px) {
.sidebar:not(.collapsed)::before {
content: "";
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: -1;
}
}
// Desktop sidebar collapsed state // Desktop sidebar collapsed state
@media (min-width: 769px) { @media (min-width: 769px) {
.sidebar.collapsed { .sidebar.collapsed {