diff --git a/src/Services/CRM/Api/Controllers/ChildrenController.cs b/src/Services/CRM/Api/Controllers/ChildrenController.cs index b191f346..5b6c22c7 100644 --- a/src/Services/CRM/Api/Controllers/ChildrenController.cs +++ b/src/Services/CRM/Api/Controllers/ChildrenController.cs @@ -2,6 +2,7 @@ using KDVManager.Services.CRM.Application.Features.Children.Commands.DeleteChild; using KDVManager.Services.CRM.Application.Features.Children.Queries.GetChildList; using MediatR; +using System.Net; using Microsoft.AspNetCore.Mvc; using KDVManager.Services.CRM.Application.Contracts.Pagination; using KDVManager.Services.CRM.Application.Features.Children.Queries.GetChildDetail; @@ -46,17 +47,20 @@ public async Task> CreateChild([FromBody] CreateChildCommand return Ok(id); } - [HttpPut("{ChildId:guid}", Name = "UpdateChild")] - public async Task> UpdateChild([FromRoute] Guid ChildId, [FromBody] UpdateChildCommand updateChildCommand) + [HttpPut("{Id:guid}", Name = "UpdateChild")] + [ProducesResponseType((int)HttpStatusCode.NoContent)] + [ProducesResponseType(typeof(UnprocessableEntityResponse), (int)HttpStatusCode.UnprocessableEntity)] + public async Task> UpdateChild([FromRoute] Guid Id, [FromBody] UpdateChildCommand updateChildCommand) { // Set the route id to the command - updateChildCommand.Id = ChildId; + updateChildCommand.Id = Id; await _mediator.Send(updateChildCommand); return NoContent(); } [HttpDelete("{Id:guid}", Name = "DeleteChild")] + [ProducesResponseType((int)HttpStatusCode.NoContent)] public async Task> DeleteChild([FromRoute] DeleteChildCommand deleteChildCommand) { await _mediator.Send(deleteChildCommand); diff --git a/src/Services/CRM/Api/Middleware/ExceptionHandlerMiddleware.cs b/src/Services/CRM/Api/Middleware/ExceptionHandlerMiddleware.cs index 9ee81828..92ace6c0 100644 --- a/src/Services/CRM/Api/Middleware/ExceptionHandlerMiddleware.cs +++ b/src/Services/CRM/Api/Middleware/ExceptionHandlerMiddleware.cs @@ -6,72 +6,66 @@ using System.Text.Json.Serialization; using Microsoft.AspNetCore.Http; -namespace KDVManager.Services.CRM.Api.Middleware +namespace KDVManager.Services.CRM.Api.Middleware; + +public class ExceptionHandlerMiddleware { - public class ExceptionHandlerMiddleware + private readonly RequestDelegate _next; + + public ExceptionHandlerMiddleware(RequestDelegate next) { - private readonly RequestDelegate _next; + _next = next; + } - public ExceptionHandlerMiddleware(RequestDelegate next) + public async Task Invoke(HttpContext context) + { + try { - _next = next; + await _next(context); } - - public async Task Invoke(HttpContext context) + catch (Exception exception) { - try - { - await _next(context); - } - catch (Exception exception) - { - await ConvertException(context, exception); - } + await ConvertException(context, exception); } + } - private Task ConvertException(HttpContext context, Exception exception) - { - HttpStatusCode httpStatusCode = HttpStatusCode.InternalServerError; - - context.Response.ContentType = "application/json"; - - var result = string.Empty; - var jsonSerializerOptions = new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }; + private Task ConvertException(HttpContext context, Exception exception) + { + HttpStatusCode httpStatusCode = HttpStatusCode.InternalServerError; - switch (exception) - { - case ValidationException validationException: - httpStatusCode = HttpStatusCode.UnprocessableEntity; - result = JsonSerializer.Serialize(new - { - status = (int)httpStatusCode, - errors = validationException.ValidationErrors - }, jsonSerializerOptions); - break; - case BadRequestException badRequestException: - httpStatusCode = HttpStatusCode.BadRequest; - break; - case NotFoundException notFoundException: - httpStatusCode = HttpStatusCode.NotFound; - break; - case Exception ex: - httpStatusCode = HttpStatusCode.BadRequest; - break; - } + context.Response.ContentType = "application/json"; - context.Response.StatusCode = (int)httpStatusCode; + var result = string.Empty; + var jsonSerializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + switch (exception) + { + case ValidationException validationException: + httpStatusCode = HttpStatusCode.UnprocessableEntity; + result = JsonSerializer.Serialize(new UnprocessableEntityResponse((int)httpStatusCode, validationException), jsonSerializerOptions); + break; + case BadRequestException badRequestException: + httpStatusCode = HttpStatusCode.BadRequest; + break; + case NotFoundException notFoundException: + httpStatusCode = HttpStatusCode.NotFound; + break; + case Exception ex: + httpStatusCode = HttpStatusCode.BadRequest; + break; + } - if (result == string.Empty) - { - result = JsonSerializer.Serialize(new { error = exception.Message, status = (int)httpStatusCode }, jsonSerializerOptions); + context.Response.StatusCode = (int)httpStatusCode; - } + if (result == string.Empty) + { + result = JsonSerializer.Serialize(new { error = exception.Message, status = (int)httpStatusCode }, jsonSerializerOptions); - return context.Response.WriteAsync(result); } + + return context.Response.WriteAsync(result); } } diff --git a/src/Services/CRM/Api/Responses/UnprocessableEntityResponse.cs b/src/Services/CRM/Api/Responses/UnprocessableEntityResponse.cs new file mode 100644 index 00000000..6010153e --- /dev/null +++ b/src/Services/CRM/Api/Responses/UnprocessableEntityResponse.cs @@ -0,0 +1,35 @@ +using System.ComponentModel.DataAnnotations; +using KDVManager.Services.CRM.Application.Exceptions; +using ValidationException = KDVManager.Services.CRM.Application.Exceptions.ValidationException; + +public class ValidationError +{ + [Required] + public required string Property { get; set; } + + [Required] + public required string Code { get; set; } + + [Required] + public required string Title { get; set; } +} + +public class UnprocessableEntityResponse +{ + [Required] + public int Status { get; set; } + + [Required] + public IEnumerable Errors { get; set; } = new List(); + + public UnprocessableEntityResponse(int status, ValidationException validationException) + { + Status = status; + Errors = validationException.ValidationErrors.Select(ve => new ValidationError + { + Property = System.Text.Json.JsonNamingPolicy.CamelCase.ConvertName(ve.Property), + Code = ve.Code, + Title = ve.Title + }); + } +} \ No newline at end of file diff --git a/src/web/output.openapi.json b/src/web/output.openapi.json index b763fb40..af1c7084 100644 --- a/src/web/output.openapi.json +++ b/src/web/output.openapi.json @@ -151,20 +151,13 @@ "tags": ["Children"], "operationId": "UpdateChild", "parameters": [ - { - "name": "childId", - "in": "query", - "schema": { - "type": "string", - "format": "uuid" - } - }, { "name": "Id", "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], @@ -188,25 +181,25 @@ } }, "responses": { - "200": { - "description": "Success", + "204": { + "description": "Success" + }, + "422": { + "description": "Client Error", "content": { "text/plain": { "schema": { - "type": "string", - "format": "uuid" + "$ref": "#/components/schemas/UnprocessableEntityResponse" } }, "application/json": { "schema": { - "type": "string", - "format": "uuid" + "$ref": "#/components/schemas/UnprocessableEntityResponse" } }, "text/json": { "schema": { - "type": "string", - "format": "uuid" + "$ref": "#/components/schemas/UnprocessableEntityResponse" } } } @@ -228,28 +221,8 @@ } ], "responses": { - "200": { - "description": "Success", - "content": { - "text/plain": { - "schema": { - "type": "string", - "format": "uuid" - } - }, - "application/json": { - "schema": { - "type": "string", - "format": "uuid" - } - }, - "text/json": { - "schema": { - "type": "string", - "format": "uuid" - } - } - } + "204": { + "description": "Success" } } } @@ -636,6 +609,23 @@ }, "additionalProperties": false }, + "UnprocessableEntityResponse": { + "required": ["errors", "status"], + "type": "object", + "properties": { + "status": { + "type": "integer", + "format": "int32" + }, + "errors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ValidationError" + } + } + }, + "additionalProperties": false + }, "UpdateChildCommand": { "type": "object", "properties": { @@ -655,6 +645,22 @@ }, "additionalProperties": false }, + "ValidationError": { + "required": ["code", "property", "title"], + "type": "object", + "properties": { + "property": { + "type": "string" + }, + "code": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "additionalProperties": false + }, "AddGroupCommand": { "type": "object", "properties": { @@ -705,39 +711,6 @@ } }, "additionalProperties": {} - }, - "UnprocessableEntityResponse": { - "required": ["errors", "status"], - "type": "object", - "properties": { - "status": { - "type": "integer", - "format": "int32" - }, - "errors": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ValidationError" - } - } - }, - "additionalProperties": false - }, - "ValidationError": { - "required": ["code", "property", "title"], - "type": "object", - "properties": { - "property": { - "type": "string" - }, - "code": { - "type": "string" - }, - "title": { - "type": "string" - } - }, - "additionalProperties": false } } } diff --git a/src/web/src/api/endpoints/children/children.ts b/src/web/src/api/endpoints/children/children.ts index 1ece3dfb..c894ce91 100644 --- a/src/web/src/api/endpoints/children/children.ts +++ b/src/web/src/api/endpoints/children/children.ts @@ -19,8 +19,8 @@ import type { ChildDetailVM } from "../../models/childDetailVM"; import type { ChildListVM } from "../../models/childListVM"; import type { CreateChildCommand } from "../../models/createChildCommand"; import type { GetAllChildrenParams } from "../../models/getAllChildrenParams"; +import type { UnprocessableEntityResponse } from "../../models/unprocessableEntityResponse"; import type { UpdateChildCommand } from "../../models/updateChildCommand"; -import type { UpdateChildParams } from "../../models/updateChildParams"; import { useExecuteFetchPaginated } from "../../mutator/useExecuteFetchPaginated"; import { useExecuteFetch } from "../../mutator/useExecuteFetch"; @@ -229,33 +229,35 @@ export const useGetChildById = < }; export const useUpdateChildHook = () => { - const updateChild = useExecuteFetch(); + const updateChild = useExecuteFetch(); return useCallback( - (id: string, updateChildCommand: UpdateChildCommand, params?: UpdateChildParams) => { + (id: string, updateChildCommand: UpdateChildCommand) => { return updateChild({ url: `/crm/v1/children/${id}`, method: "PUT", headers: { "Content-Type": "application/json" }, data: updateChildCommand, - params, }); }, [updateChild], ); }; -export const useUpdateChildMutationOptions = (options?: { +export const useUpdateChildMutationOptions = < + TError = UnprocessableEntityResponse, + TContext = unknown, +>(options?: { mutation?: UseMutationOptions< Awaited>>, TError, - { id: string; data: UpdateChildCommand; params?: UpdateChildParams }, + { id: string; data: UpdateChildCommand }, TContext >; }): UseMutationOptions< Awaited>>, TError, - { id: string; data: UpdateChildCommand; params?: UpdateChildParams }, + { id: string; data: UpdateChildCommand }, TContext > => { const { mutation: mutationOptions } = options ?? {}; @@ -264,11 +266,11 @@ export const useUpdateChildMutationOptions = >>, - { id: string; data: UpdateChildCommand; params?: UpdateChildParams } + { id: string; data: UpdateChildCommand } > = (props) => { - const { id, data, params } = props ?? {}; + const { id, data } = props ?? {}; - return updateChild(id, data, params); + return updateChild(id, data); }; return { mutationFn, ...mutationOptions }; @@ -278,19 +280,19 @@ export type UpdateChildMutationResult = NonNullable< Awaited>> >; export type UpdateChildMutationBody = UpdateChildCommand; -export type UpdateChildMutationError = unknown; +export type UpdateChildMutationError = UnprocessableEntityResponse; -export const useUpdateChild = (options?: { +export const useUpdateChild = (options?: { mutation?: UseMutationOptions< Awaited>>, TError, - { id: string; data: UpdateChildCommand; params?: UpdateChildParams }, + { id: string; data: UpdateChildCommand }, TContext >; }): UseMutationResult< Awaited>>, TError, - { id: string; data: UpdateChildCommand; params?: UpdateChildParams }, + { id: string; data: UpdateChildCommand }, TContext > => { const mutationOptions = useUpdateChildMutationOptions(options); @@ -298,7 +300,7 @@ export const useUpdateChild = (options?: { return useMutation(mutationOptions); }; export const useDeleteChildHook = () => { - const deleteChild = useExecuteFetch(); + const deleteChild = useExecuteFetch(); return useCallback( (id: string) => { diff --git a/src/web/src/api/models/updateChildParams.ts b/src/web/src/api/models/updateChildParams.ts deleted file mode 100644 index 9776ea83..00000000 --- a/src/web/src/api/models/updateChildParams.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Generated by orval v6.31.0 🍺 - * Do not edit manually. - * KDVManager CRM API - * OpenAPI spec version: v1 - */ - -export type UpdateChildParams = { - childId?: string; -}; diff --git a/src/web/src/features/children/ChildrenTable.tsx b/src/web/src/features/children/ChildrenTable.tsx index ac0e6d94..a3cb5899 100644 --- a/src/web/src/features/children/ChildrenTable.tsx +++ b/src/web/src/features/children/ChildrenTable.tsx @@ -1,11 +1,12 @@ import { type GridColDef } from "@mui/x-data-grid/models/colDef"; -import { DataGrid, type GridRowParams } from "@mui/x-data-grid"; +import { DataGrid, type GridRenderCellParams } from "@mui/x-data-grid"; import { type ChildListVM } from "@api/models/childListVM"; import { keepPreviousData } from "@tanstack/react-query"; import { useGetAllChildren } from "@api/endpoints/children/children"; import { usePagination } from "@hooks/usePagination"; import dayjs from "dayjs"; -import { useNavigate } from "react-router-dom"; +import { DeleteChildButton } from "./DeleteChildButton"; +import { EditChildButton } from "./EditChildButton"; const columns: GridColDef[] = [ { @@ -23,19 +24,26 @@ const columns: GridColDef[] = [ disableReorder: true, valueFormatter: (value) => dayjs(value).format("DD/MM/YYYY"), }, + { + field: "id", + headerName: "Actions", + sortable: false, + disableColumnMenu: true, + renderCell: (params: GridRenderCellParams) => ( + <> + + + + ), + }, ]; export const ChildrenTable = () => { - const navigate = useNavigate(); const { apiPagination, muiPagination } = usePagination(); const { data, isLoading, isFetching } = useGetAllChildren(apiPagination, { query: { placeholderData: keepPreviousData }, }); - const handleRowClick = (params: GridRowParams) => { - navigate(`/children/${params.id}`); - }; - return ( autoHeight @@ -45,7 +53,6 @@ export const ChildrenTable = () => { columns={columns} rows={data?.value || []} disableRowSelectionOnClick - onRowClick={handleRowClick} {...muiPagination} /> ); diff --git a/src/web/src/features/children/DeleteChildButton.tsx b/src/web/src/features/children/DeleteChildButton.tsx new file mode 100644 index 00000000..c398b6cf --- /dev/null +++ b/src/web/src/features/children/DeleteChildButton.tsx @@ -0,0 +1,41 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { useSnackbar } from "notistack"; +import IconButton from "@mui/material/IconButton/IconButton"; +import DeleteIcon from "@mui/icons-material/Delete"; +import { useDeleteChild, getGetAllChildrenQueryKey } from "@api/endpoints/children/children"; + +type DeleteChildButtonProps = { + id: string; + fullName?: string; +}; + +export const DeleteChildButton: React.FC = ({ id, fullName }) => { + const { t } = useTranslation(); + const mutate = useDeleteChild(); + const queryClient = useQueryClient(); + const { enqueueSnackbar } = useSnackbar(); + + const handleOnDeleteClick = async () => { + await mutate.mutateAsync({ id: id }, { onSuccess: onMutateSuccess, onError: onMutateError }); + }; + + const onMutateSuccess = () => { + queryClient.invalidateQueries({ queryKey: getGetAllChildrenQueryKey() }); + const message = fullName + ? t("{{fullName}} has been deleted", { fullName }) + : t("Child has been deleted"); + enqueueSnackbar(message, { variant: "success" }); + }; + + const onMutateError = (error: any) => { + enqueueSnackbar(t("Error occurred while deleting child"), { variant: "error" }); + console.error("Error deleting child:", error); + }; + + return ( + + + + ); +}; diff --git a/src/web/src/features/children/EditChildButton.tsx b/src/web/src/features/children/EditChildButton.tsx new file mode 100644 index 00000000..970088b5 --- /dev/null +++ b/src/web/src/features/children/EditChildButton.tsx @@ -0,0 +1,21 @@ +import IconButton from "@mui/material/IconButton/IconButton"; +import EditIcon from "@mui/icons-material/Edit"; +import { useNavigate } from "react-router-dom"; + +type EditChildButtonProps = { + id: string; +}; + +export const EditChildButton: React.FC = ({ id }) => { + const navigate = useNavigate(); + + const handleOnEditClick = () => { + navigate(`/children/${id}`); + }; + + return ( + + + + ); +}; diff --git a/src/web/src/pages/children/UpdateChildPage.tsx b/src/web/src/pages/children/UpdateChildPage.tsx index b4fd66dd..b25cd3e9 100644 --- a/src/web/src/pages/children/UpdateChildPage.tsx +++ b/src/web/src/pages/children/UpdateChildPage.tsx @@ -1,6 +1,5 @@ import { Controller, useForm } from "react-hook-form"; import { FormContainer, TextFieldElement } from "react-hook-form-mui"; -import Button from "@mui/material/Button"; import Paper from "@mui/material/Paper"; import Grid from "@mui/material/Grid"; import { DatePicker } from "@mui/x-date-pickers/DatePicker"; @@ -9,23 +8,33 @@ import { useGetChildById, useUpdateChild, } from "@api/endpoints/children/children"; -import { useNavigate, useParams } from "react-router-dom"; +import { useParams } from "react-router-dom"; import { useQueryClient } from "@tanstack/react-query"; import dayjs from "dayjs"; import { useEffect } from "react"; import { type UpdateChildCommand } from "@api/models/updateChildCommand"; +import { type UnprocessableEntityResponse } from "@api/models/unprocessableEntityResponse"; +import { useSnackbar } from "notistack"; +import { useTranslation } from "react-i18next"; +import LoadingButton from "@mui/lab/LoadingButton/LoadingButton"; const UpdateChildPage = () => { const { childId } = useParams() as { childId: string }; + const { t } = useTranslation(); + const { enqueueSnackbar } = useSnackbar(); const queryClient = useQueryClient(); - const { mutate } = useUpdateChild(); - const navigate = useNavigate(); + const { mutateAsync } = useUpdateChild(); const { data: child } = useGetChildById(childId); const formContext = useForm({}); - const { reset } = formContext; + const { + handleSubmit, + reset, + setError, + formState: { isValid, isDirty, isSubmitting }, + } = formContext; useEffect(() => { if (child) { @@ -37,15 +46,26 @@ const UpdateChildPage = () => { } }, [child, reset]); - const handleSubmit = formContext.handleSubmit; - const onSubmit = (data: UpdateChildCommand) => { - mutate({ id: childId, data: data }, { onSuccess: onSuccess }); + mutateAsync( + { id: childId, data: data }, + { onSuccess: onMutateSuccess, onError: onMutateError }, + ); }; - const onSuccess = () => { + const onMutateSuccess = () => { void queryClient.invalidateQueries({ queryKey: getGetAllChildrenQueryKey() }); - navigate("/children"); + enqueueSnackbar(t("Child updated"), { variant: "success" }); + reset({}, { keepValues: true }); + }; + + const onMutateError = (error: UnprocessableEntityResponse) => { + error.errors.forEach((propertyError) => { + setError(propertyError.property as any, { + type: "server", + message: propertyError.title, + }); + }); }; return ( @@ -79,7 +99,14 @@ const UpdateChildPage = () => { > - + + {t("Save")} +