Compare commits
4 Commits
5dfca873dd
...
2e226e60f5
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e226e60f5 | |||
| 68649223d9 | |||
| 9d7c224ad2 | |||
| 47aec287aa |
@@ -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="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">
|
||||||
|
|||||||
+3
-3
@@ -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()"
|
||||||
|
|||||||
+59
-2
@@ -14,6 +14,8 @@
|
|||||||
<button kendoButton (click)="applyFilter()">Apply</button>
|
<button kendoButton (click)="applyFilter()">Apply</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop / tablet: full data grid -->
|
||||||
|
<div class="hidden md:block">
|
||||||
<kendo-grid [data]="{ data: rows, total: total }" [loading]="loading" [pageable]="true" [skip]="skip"
|
<kendo-grid [data]="{ data: rows, total: total }" [loading]="loading" [pageable]="true" [skip]="skip"
|
||||||
[pageSize]="pageSize" (pageChange)="onPageChange($event)">
|
[pageSize]="pageSize" (pageChange)="onPageChange($event)">
|
||||||
|
|
||||||
@@ -55,9 +57,64 @@
|
|||||||
</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.
|
||||||
|
|||||||
+109
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+15
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -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)"
|
||||||
|
|||||||
+34
-3
@@ -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 = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -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',
|
||||||
|
|||||||
+1
-1
@@ -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">
|
||||||
|
|||||||
+43
-5
@@ -3,6 +3,8 @@
|
|||||||
<button kendoButton themeColor="primary" (click)="openNew()">+ New Reimbursement</button>
|
<button kendoButton themeColor="primary" (click)="openNew()">+ New Reimbursement</button>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
|
<!-- Desktop / tablet: full data grid -->
|
||||||
|
<div class="hidden md:block">
|
||||||
<kendo-grid [data]="rows" [loading]="loading">
|
<kendo-grid [data]="rows" [loading]="loading">
|
||||||
<kendo-grid-column field="expenseDate" title="Date" [width]="110"></kendo-grid-column>
|
<kendo-grid-column field="expenseDate" title="Date" [width]="110"></kendo-grid-column>
|
||||||
<kendo-grid-column field="description" title="Description"></kendo-grid-column>
|
<kendo-grid-column field="description" title="Description"></kendo-grid-column>
|
||||||
@@ -20,16 +22,52 @@
|
|||||||
</kendo-grid-column>
|
</kendo-grid-column>
|
||||||
<kendo-grid-column title="Actions" [width]="200">
|
<kendo-grid-column title="Actions" [width]="200">
|
||||||
<ng-template kendoGridCellTemplate let-dataItem>
|
<ng-template kendoGridCellTemplate let-dataItem>
|
||||||
<ng-container *ngIf="canEdit(dataItem)">
|
<button *ngIf="canEdit(dataItem)" kendoButton fillMode="flat" (click)="openEdit(dataItem)">Edit</button>
|
||||||
<button kendoButton fillMode="flat" (click)="openEdit(dataItem)">Edit</button>
|
<button *ngIf="isDraft(dataItem)" kendoButton themeColor="primary" fillMode="flat" (click)="submit(dataItem)">Submit</button>
|
||||||
<button kendoButton themeColor="primary" fillMode="flat" (click)="submit(dataItem)">Submit</button>
|
<button *ngIf="isDraft(dataItem)" kendoButton fillMode="flat" (click)="remove(dataItem)">Delete</button>
|
||||||
<button kendoButton fillMode="flat" (click)="remove(dataItem)">Delete</button>
|
|
||||||
</ng-container>
|
|
||||||
<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"
|
||||||
|
|||||||
+88
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+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] ?? '';
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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>
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
+3
-3
@@ -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>
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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,11 +336,18 @@
|
|||||||
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;
|
||||||
|
|
||||||
|
// Only apply hover on devices that can truly hover (desktop). On touch, hover
|
||||||
|
// styles "stick" after a tap and leave a muddy box on the last-tapped item.
|
||||||
|
@media (hover: hover) {
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(30, 64, 175, 0.1);
|
background: rgba(30, 64, 175, 0.1);
|
||||||
color: #1e40af;
|
color: #1e40af;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: rgba(30, 64, 175, 0.15);
|
background: rgba(30, 64, 175, 0.15);
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user