diff --git a/src/AccountTracking.Web/nswag.json b/src/AccountTracking.Web/nswag.json
new file mode 100644
index 0000000..0828d24
--- /dev/null
+++ b/src/AccountTracking.Web/nswag.json
@@ -0,0 +1,75 @@
+{
+ "runtime": "Default",
+ "defaultVariables": null,
+ "documentGenerator": {
+ "fromDocument": {
+ "url": "openapi.json",
+ "output": null,
+ "newLineBehavior": "Auto"
+ }
+ },
+ "codeGenerators": {
+ "openApiToTypeScriptClient": {
+ "className": "{controller}Client",
+ "moduleName": "",
+ "namespace": "",
+ "typeScriptVersion": 5.0,
+ "template": "Fetch",
+ "promiseType": "Promise",
+ "httpClass": "HttpClient",
+ "withCredentials": false,
+ "useSingletonProvider": false,
+ "injectionTokenType": "OpaqueToken",
+ "rxJsVersion": 6.0,
+ "dateTimeType": "String",
+ "nullValue": "Undefined",
+ "generateClientClasses": true,
+ "generateClientInterfaces": false,
+ "generateOptionalParameters": false,
+ "exportTypes": true,
+ "wrapDtoExceptions": false,
+ "exceptionClass": "SwaggerException",
+ "clientBaseClass": null,
+ "wrapResponses": false,
+ "wrapResponseMethods": [],
+ "generateResponseClasses": true,
+ "responseClass": "SwaggerResponse",
+ "protectedMethods": [],
+ "configurationClass": null,
+ "useTransformOptionsMethod": false,
+ "useTransformResultMethod": false,
+ "generateDtoTypes": true,
+ "operationGenerationMode": "MultipleClientsFromOperationId",
+ "includedOperationIds": [],
+ "excludedOperationIds": [],
+ "markOptionalProperties": true,
+ "generateCloneMethod": false,
+ "typeStyle": "Interface",
+ "enumStyle": "Enum",
+ "useLeafType": false,
+ "classTypes": [],
+ "extendedClasses": [],
+ "extensionCode": null,
+ "generateDefaultValues": true,
+ "excludedTypeNames": [],
+ "excludedParameterNames": [],
+ "handleReferences": false,
+ "generateTypeCheckFunctions": false,
+ "generateConstructorInterface": true,
+ "convertConstructorInterfaceData": false,
+ "importRequiredTypes": true,
+ "useGetBaseUrlMethod": false,
+ "baseUrlTokenName": "API_BASE_URL",
+ "queryNullValue": "",
+ "useAbortSignal": false,
+ "inlineNamedDictionaries": false,
+ "inlineNamedAny": false,
+ "includeHttpContext": false,
+ "templateDirectory": null,
+ "serviceHost": null,
+ "serviceSchemes": null,
+ "output": "src/api/apiClient.ts",
+ "newLineBehavior": "Auto"
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/AccountTracking.Web/src/api/apiClient.ts b/src/AccountTracking.Web/src/api/apiClient.ts
new file mode 100644
index 0000000..564f826
--- /dev/null
+++ b/src/AccountTracking.Web/src/api/apiClient.ts
@@ -0,0 +1,470 @@
+//----------------------
+//
+// Generated using the NSwag toolchain v14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0)) (http://NSwag.org)
+//
+//----------------------
+
+/* eslint-disable */
+// ReSharper disable InconsistentNaming
+
+export class AuthClient {
+ private http: { fetch(url: RequestInfo, init?: RequestInit): Promise };
+ private baseUrl: string;
+ protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined;
+
+ constructor(baseUrl?: string, http?: { fetch(url: RequestInfo, init?: RequestInit): Promise }) {
+ this.http = http ? http : window as any;
+ this.baseUrl = baseUrl ?? "http://localhost:5099";
+ }
+
+ login(request: LoginRequest): Promise {
+ let url_ = this.baseUrl + "/api/auth/login";
+ url_ = url_.replace(/[?&]$/, "");
+
+ const content_ = JSON.stringify(request);
+
+ let options_: RequestInit = {
+ body: content_,
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Accept": "application/json"
+ }
+ };
+
+ return this.http.fetch(url_, options_).then((_response: Response) => {
+ return this.processLogin(_response);
+ });
+ }
+
+ protected processLogin(response: Response): Promise {
+ const status = response.status;
+ let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
+ if (status === 200) {
+ return response.text().then((_responseText) => {
+ let result200: any = null;
+ result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as LoginResponse;
+ return result200;
+ });
+ } else if (status === 401) {
+ return response.text().then((_responseText) => {
+ let result401: any = null;
+ result401 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
+ return throwException("A server side error occurred.", status, _responseText, _headers, result401);
+ });
+ } else if (status !== 200 && status !== 204) {
+ return response.text().then((_responseText) => {
+ return throwException("An unexpected server error occurred.", status, _responseText, _headers);
+ });
+ }
+ return Promise.resolve(null as any);
+ }
+}
+
+export class DashboardClient {
+ private http: { fetch(url: RequestInfo, init?: RequestInit): Promise };
+ private baseUrl: string;
+ protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined;
+
+ constructor(baseUrl?: string, http?: { fetch(url: RequestInfo, init?: RequestInit): Promise }) {
+ this.http = http ? http : window as any;
+ this.baseUrl = baseUrl ?? "http://localhost:5099";
+ }
+
+ summary(year: number | undefined, month: number | null | undefined): Promise {
+ let url_ = this.baseUrl + "/api/dashboard/summary?";
+ if (year === null)
+ throw new globalThis.Error("The parameter 'year' cannot be null.");
+ else if (year !== undefined)
+ url_ += "year=" + encodeURIComponent("" + year) + "&";
+ if (month !== undefined && month !== null)
+ url_ += "month=" + encodeURIComponent("" + month) + "&";
+ url_ = url_.replace(/[?&]$/, "");
+
+ let options_: RequestInit = {
+ method: "GET",
+ headers: {
+ "Accept": "application/json"
+ }
+ };
+
+ return this.http.fetch(url_, options_).then((_response: Response) => {
+ return this.processSummary(_response);
+ });
+ }
+
+ protected processSummary(response: Response): Promise {
+ const status = response.status;
+ let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
+ if (status === 200) {
+ return response.text().then((_responseText) => {
+ let result200: any = null;
+ result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as SummaryDto;
+ return result200;
+ });
+ } else if (status !== 200 && status !== 204) {
+ return response.text().then((_responseText) => {
+ return throwException("An unexpected server error occurred.", status, _responseText, _headers);
+ });
+ }
+ return Promise.resolve(null as any);
+ }
+
+ spendingByCategory(year: number | undefined, month: number | null | undefined): Promise {
+ let url_ = this.baseUrl + "/api/dashboard/spending-by-category?";
+ if (year === null)
+ throw new globalThis.Error("The parameter 'year' cannot be null.");
+ else if (year !== undefined)
+ url_ += "year=" + encodeURIComponent("" + year) + "&";
+ if (month !== undefined && month !== null)
+ url_ += "month=" + encodeURIComponent("" + month) + "&";
+ url_ = url_.replace(/[?&]$/, "");
+
+ let options_: RequestInit = {
+ method: "GET",
+ headers: {
+ "Accept": "application/json"
+ }
+ };
+
+ return this.http.fetch(url_, options_).then((_response: Response) => {
+ return this.processSpendingByCategory(_response);
+ });
+ }
+
+ protected processSpendingByCategory(response: Response): Promise {
+ const status = response.status;
+ let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
+ if (status === 200) {
+ return response.text().then((_responseText) => {
+ let result200: any = null;
+ result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as CategorySpendingDto[];
+ return result200;
+ });
+ } else if (status !== 200 && status !== 204) {
+ return response.text().then((_responseText) => {
+ return throwException("An unexpected server error occurred.", status, _responseText, _headers);
+ });
+ }
+ return Promise.resolve(null as any);
+ }
+
+ monthlyBalances(year: number | undefined): Promise {
+ let url_ = this.baseUrl + "/api/dashboard/monthly-balances?";
+ if (year === null)
+ throw new globalThis.Error("The parameter 'year' cannot be null.");
+ else if (year !== undefined)
+ url_ += "year=" + encodeURIComponent("" + year) + "&";
+ url_ = url_.replace(/[?&]$/, "");
+
+ let options_: RequestInit = {
+ method: "GET",
+ headers: {
+ "Accept": "application/json"
+ }
+ };
+
+ return this.http.fetch(url_, options_).then((_response: Response) => {
+ return this.processMonthlyBalances(_response);
+ });
+ }
+
+ protected processMonthlyBalances(response: Response): Promise {
+ const status = response.status;
+ let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
+ if (status === 200) {
+ return response.text().then((_responseText) => {
+ let result200: any = null;
+ result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as MonthlyBalanceDto[];
+ return result200;
+ });
+ } else if (status !== 200 && status !== 204) {
+ return response.text().then((_responseText) => {
+ return throwException("An unexpected server error occurred.", status, _responseText, _headers);
+ });
+ }
+ return Promise.resolve(null as any);
+ }
+
+ cumulativeSpending(year: number | undefined, month: number | undefined): Promise {
+ let url_ = this.baseUrl + "/api/dashboard/cumulative-spending?";
+ if (year === null)
+ throw new globalThis.Error("The parameter 'year' cannot be null.");
+ else if (year !== undefined)
+ url_ += "year=" + encodeURIComponent("" + year) + "&";
+ if (month === null)
+ throw new globalThis.Error("The parameter 'month' cannot be null.");
+ else if (month !== undefined)
+ url_ += "month=" + encodeURIComponent("" + month) + "&";
+ url_ = url_.replace(/[?&]$/, "");
+
+ let options_: RequestInit = {
+ method: "GET",
+ headers: {
+ "Accept": "application/json"
+ }
+ };
+
+ return this.http.fetch(url_, options_).then((_response: Response) => {
+ return this.processCumulativeSpending(_response);
+ });
+ }
+
+ protected processCumulativeSpending(response: Response): Promise {
+ const status = response.status;
+ let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
+ if (status === 200) {
+ return response.text().then((_responseText) => {
+ let result200: any = null;
+ result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as CumulativeSpendingDto[];
+ return result200;
+ });
+ } else if (status !== 200 && status !== 204) {
+ return response.text().then((_responseText) => {
+ return throwException("An unexpected server error occurred.", status, _responseText, _headers);
+ });
+ }
+ return Promise.resolve(null as any);
+ }
+}
+
+export class TransactionsClient {
+ private http: { fetch(url: RequestInfo, init?: RequestInit): Promise };
+ private baseUrl: string;
+ protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined;
+
+ constructor(baseUrl?: string, http?: { fetch(url: RequestInfo, init?: RequestInit): Promise }) {
+ this.http = http ? http : window as any;
+ this.baseUrl = baseUrl ?? "http://localhost:5099";
+ }
+
+ import(file: FileParameter | null | undefined): Promise {
+ let url_ = this.baseUrl + "/api/transactions/import";
+ url_ = url_.replace(/[?&]$/, "");
+
+ const content_ = new FormData();
+ if (file !== null && file !== undefined)
+ content_.append("file", file.data, file.fileName ? file.fileName : "file");
+
+ let options_: RequestInit = {
+ body: content_,
+ method: "POST",
+ headers: {
+ "Accept": "application/json"
+ }
+ };
+
+ return this.http.fetch(url_, options_).then((_response: Response) => {
+ return this.processImport(_response);
+ });
+ }
+
+ protected processImport(response: Response): Promise {
+ const status = response.status;
+ let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
+ if (status === 200) {
+ return response.text().then((_responseText) => {
+ let result200: any = null;
+ result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ImportResult;
+ return result200;
+ });
+ } else if (status === 400) {
+ return response.text().then((_responseText) => {
+ let result400: any = null;
+ result400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ErrorResult;
+ return throwException("A server side error occurred.", status, _responseText, _headers, result400);
+ });
+ } else if (status !== 200 && status !== 204) {
+ return response.text().then((_responseText) => {
+ return throwException("An unexpected server error occurred.", status, _responseText, _headers);
+ });
+ }
+ return Promise.resolve(null as any);
+ }
+
+ list(year: number | undefined, month: number | null | undefined, category: string | null | undefined, search: string | null | undefined, page: number | undefined, pageSize: number | undefined): Promise {
+ let url_ = this.baseUrl + "/api/transactions?";
+ if (year === null)
+ throw new globalThis.Error("The parameter 'year' cannot be null.");
+ else if (year !== undefined)
+ url_ += "year=" + encodeURIComponent("" + year) + "&";
+ if (month !== undefined && month !== null)
+ url_ += "month=" + encodeURIComponent("" + month) + "&";
+ if (category !== undefined && category !== null)
+ url_ += "category=" + encodeURIComponent("" + category) + "&";
+ if (search !== undefined && search !== null)
+ url_ += "search=" + encodeURIComponent("" + search) + "&";
+ if (page === null)
+ throw new globalThis.Error("The parameter 'page' cannot be null.");
+ else if (page !== undefined)
+ url_ += "page=" + encodeURIComponent("" + page) + "&";
+ if (pageSize === null)
+ throw new globalThis.Error("The parameter 'pageSize' cannot be null.");
+ else if (pageSize !== undefined)
+ url_ += "pageSize=" + encodeURIComponent("" + pageSize) + "&";
+ url_ = url_.replace(/[?&]$/, "");
+
+ let options_: RequestInit = {
+ method: "GET",
+ headers: {
+ "Accept": "application/json"
+ }
+ };
+
+ return this.http.fetch(url_, options_).then((_response: Response) => {
+ return this.processList(_response);
+ });
+ }
+
+ protected processList(response: Response): Promise {
+ const status = response.status;
+ let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
+ if (status === 200) {
+ return response.text().then((_responseText) => {
+ let result200: any = null;
+ result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as TransactionListResponse;
+ return result200;
+ });
+ } else if (status !== 200 && status !== 204) {
+ return response.text().then((_responseText) => {
+ return throwException("An unexpected server error occurred.", status, _responseText, _headers);
+ });
+ }
+ return Promise.resolve(null as any);
+ }
+
+ categories(): Promise {
+ let url_ = this.baseUrl + "/api/transactions/categories";
+ url_ = url_.replace(/[?&]$/, "");
+
+ let options_: RequestInit = {
+ method: "GET",
+ headers: {
+ "Accept": "application/json"
+ }
+ };
+
+ return this.http.fetch(url_, options_).then((_response: Response) => {
+ return this.processCategories(_response);
+ });
+ }
+
+ protected processCategories(response: Response): Promise {
+ const status = response.status;
+ let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
+ if (status === 200) {
+ return response.text().then((_responseText) => {
+ let result200: any = null;
+ result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as string[];
+ return result200;
+ });
+ } else if (status !== 200 && status !== 204) {
+ return response.text().then((_responseText) => {
+ return throwException("An unexpected server error occurred.", status, _responseText, _headers);
+ });
+ }
+ return Promise.resolve(null as any);
+ }
+}
+
+export interface LoginResponse {
+ token?: string;
+ expiresAt?: string;
+}
+
+export interface ProblemDetails {
+ type?: string | undefined;
+ title?: string | undefined;
+ status?: number | undefined;
+ detail?: string | undefined;
+ instance?: string | undefined;
+
+ [key: string]: any;
+}
+
+export interface LoginRequest {
+ username?: string;
+ password?: string;
+}
+
+export interface SummaryDto {
+ totalSpent?: number;
+ totalIncome?: number;
+}
+
+export interface CategorySpendingDto {
+ category?: string;
+ total?: number;
+}
+
+export interface MonthlyBalanceDto {
+ month?: number;
+ closingBalance?: number;
+}
+
+export interface CumulativeSpendingDto {
+ day?: number;
+ cumulativeSpent?: number;
+}
+
+export interface ImportResult {
+ recordsImported?: number;
+ recordsSkipped?: number;
+}
+
+export interface ErrorResult {
+ error?: string;
+}
+
+export interface TransactionListResponse {
+ items?: TransactionDto[];
+ totalCount?: number;
+ page?: number;
+ pageSize?: number;
+}
+
+export interface TransactionDto {
+ id?: number;
+ bookingDate?: string;
+ counterPartyName?: string | undefined;
+ category?: string | undefined;
+ amount?: number;
+ balance?: number;
+ message?: string | undefined;
+}
+
+export interface FileParameter {
+ data: any;
+ fileName: string;
+}
+
+export class SwaggerException extends Error {
+ override message: string;
+ status: number;
+ response: string;
+ headers: { [key: string]: any; };
+ result: any;
+
+ constructor(message: string, status: number, response: string, headers: { [key: string]: any; }, result: any) {
+ super();
+
+ this.message = message;
+ this.status = status;
+ this.response = response;
+ this.headers = headers;
+ this.result = result;
+ }
+
+ protected isSwaggerException = true;
+
+ static isSwaggerException(obj: any): obj is SwaggerException {
+ return obj.isSwaggerException === true;
+ }
+}
+
+function throwException(message: string, status: number, response: string, headers: { [key: string]: any; }, result?: any): any {
+ if (result !== null && result !== undefined)
+ throw result;
+ else
+ throw new SwaggerException(message, status, response, headers, null);
+}
\ No newline at end of file
diff --git a/src/AccountTracking.Web/src/api/index.ts b/src/AccountTracking.Web/src/api/index.ts
new file mode 100644
index 0000000..eb90709
--- /dev/null
+++ b/src/AccountTracking.Web/src/api/index.ts
@@ -0,0 +1,27 @@
+import { AuthClient, TransactionsClient, DashboardClient } from './apiClient'
+
+// Custom fetch that injects the JWT and handles 401
+class AuthFetch {
+ fetch(url: RequestInfo, init?: RequestInit): Promise {
+ const token = localStorage.getItem('token')
+ const headers = new Headers(init?.headers)
+ if (token) headers.set('Authorization', `Bearer ${token}`)
+
+ return fetch(url, { ...init, headers }).then((res) => {
+ if (res.status === 401) {
+ localStorage.removeItem('token')
+ localStorage.removeItem('expiresAt')
+ // Navigate to login — import router lazily to avoid circular dep
+ import('../router').then(({ default: router }) => router.push('/login'))
+ }
+ return res
+ })
+ }
+}
+
+const httpClient = new AuthFetch()
+
+// Base URL is empty: NSwag generates full paths including /api/ prefix
+export const authApi = new AuthClient('', httpClient)
+export const transactionsApi = new TransactionsClient('', httpClient)
+export const dashboardApi = new DashboardClient('', httpClient)