update mobile view for expense.
This commit is contained in:
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
-1
@@ -1,4 +1,4 @@
|
|||||||
<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">
|
||||||
|
|
||||||
<!-- Member picker (finance creating on behalf of a member) -->
|
<!-- Member picker (finance creating on behalf of a member) -->
|
||||||
|
|||||||
@@ -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,6 +94,12 @@
|
|||||||
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" (close)="payRow = null">
|
||||||
<div class="grid grid-cols-1 gap-3 p-2">
|
<div class="grid grid-cols-1 gap-3 p-2">
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -82,6 +85,21 @@ export class ExpensesPageComponent implements OnInit {
|
|||||||
).subscribe(() => { this.reimbDialogOpen = false; this.load(); });
|
).subscribe(() => { this.reimbDialogOpen = false; 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 {
|
||||||
this.api.approve(row.id).subscribe(() => this.load());
|
this.api.approve(row.id).subscribe(() => this.load());
|
||||||
}
|
}
|
||||||
@@ -123,6 +141,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;
|
||||||
|
|||||||
+65
-27
@@ -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 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"
|
||||||
|
|||||||
+90
@@ -44,3 +44,93 @@
|
|||||||
color: #1d4ed8;
|
color: #1d4ed8;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mobile card list
|
||||||
|
.rmb-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+4
-1
@@ -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] ?? '';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user