From b2c89edf76ad15327065a0b02b58408a57fa45c6 Mon Sep 17 00:00:00 2001 From: Divyanshi Awasthi Date: Tue, 26 May 2026 14:48:52 +0530 Subject: [PATCH 1/2] feat(tables): implement real table management APIs --- .../admin/store/tables/TableCard.jsx | 154 ++++++---- .../admin/store/tables/TableFormModal.jsx | 85 +++++- .../admin/store/tables/TableQRModal.jsx | 6 +- .../components/admin/store/tables/Tables.jsx | 14 +- .../admin/store/tables/TablesGrid.jsx | 66 +++-- .../admin/store/tables/TablesStatusLegend.jsx | 9 +- .../qrmenu/branch/dto/BranchResponseDTO.java | 6 +- .../qrmenu/branch/mapper/BranchMapper.java | 15 +- .../table/controller/TableController.java | 136 +++++++++ .../qrmenu/table/dto/TableRequestDTO.java | 31 ++ .../qrmenu/table/dto/TableResponseDTO.java | 32 +++ .../restroly/qrmenu/table/entity/Tables.java | 37 ++- .../table/repository/TablesRepository.java | 10 +- .../qrmenu/table/service/TableService.java | 19 ++ .../table/service/TableServiceImpl.java | 141 +++++++++ .../table/service/TableServiceImplTest.java | 270 ++++++++++++++++++ 16 files changed, 915 insertions(+), 116 deletions(-) create mode 100644 RestroHub/src/main/java/com/restroly/qrmenu/table/controller/TableController.java create mode 100644 RestroHub/src/main/java/com/restroly/qrmenu/table/dto/TableRequestDTO.java create mode 100644 RestroHub/src/main/java/com/restroly/qrmenu/table/dto/TableResponseDTO.java create mode 100644 RestroHub/src/main/java/com/restroly/qrmenu/table/service/TableService.java create mode 100644 RestroHub/src/main/java/com/restroly/qrmenu/table/service/TableServiceImpl.java create mode 100644 RestroHub/src/test/java/com/restroly/qrmenu/table/service/TableServiceImplTest.java diff --git a/RestroHub-FrontEnd/src/components/admin/store/tables/TableCard.jsx b/RestroHub-FrontEnd/src/components/admin/store/tables/TableCard.jsx index b997ed5..009bbc6 100644 --- a/RestroHub-FrontEnd/src/components/admin/store/tables/TableCard.jsx +++ b/RestroHub-FrontEnd/src/components/admin/store/tables/TableCard.jsx @@ -1,8 +1,21 @@ import { useState } from 'react'; -import { QrCode, Edit2, Trash2, Users, Loader2 } from 'lucide-react'; +import { QrCode, Edit2, Trash2, Users, Loader2, RotateCcw } from 'lucide-react'; +import api from '@services/common/api'; -const TableCard = ({ table, onShowQR, onEdit, onDelete }) => { - const [deleting, setDeleting] = useState(false); +const normalizeTable = (table) => ({ + id: table.tableId, + tableId: table.tableId, + branchId: table.branchId, + number: table.tableNumber, + tableNumber: table.tableNumber, + capacity: table.capacity || 4, + status: table.status || 'available', + qrCodeUrl: table.qrCodeUrl, + isActive: table.isActive !== false, +}); + +const TableCard = ({ table, onShowQR, onEdit, onDelete, onRestore }) => { + const [saving, setSaving] = useState(false); const statusStyles = { available: { @@ -23,38 +36,58 @@ const TableCard = ({ table, onShowQR, onEdit, onDelete }) => { dot: 'bg-yellow-500', numberBg: 'bg-yellow-50', }, + inactive: { + border: 'border-gray-200 hover:border-gray-300', + badge: 'bg-gray-100 text-gray-600', + dot: 'bg-gray-400', + numberBg: 'bg-gray-50', + }, }; - const styles = statusStyles[table.status] || statusStyles.available; + const displayStatus = table.isActive ? table.status : 'inactive'; + const styles = statusStyles[displayStatus] || statusStyles.available; const handleDelete = async (e) => { e.stopPropagation(); if (!window.confirm(`Delete Table ${table.number}?`)) return; + try { - setDeleting(true); - // 🔌 await api.delete(`/api/tables/${table.id}`); - await new Promise((r) => setTimeout(r, 300)); - onDelete(table.id); + setSaving(true); + await api.delete(`/secure/api/v1/tables/${table.id}`); + onDelete({ ...table, isActive: false }); } catch (err) { console.error('Delete failed:', err); + alert(err.response?.data?.message || 'Failed to delete table'); } finally { - setDeleting(false); + setSaving(false); + } + }; + + const handleRestore = async (e) => { + e.stopPropagation(); + + try { + setSaving(true); + const response = await api.patch(`/secure/api/v1/tables/${table.id}/restore`); + onRestore(normalizeTable(response.data)); + } catch (err) { + console.error('Restore failed:', err); + alert(err.response?.data?.message || 'Failed to restore table'); + } finally { + setSaving(false); } }; return (
onShowQR(table)} + onClick={() => table.isActive && onShowQR(table)} className={` - cursor-pointer overflow-hidden rounded-2xl border-2 - bg-white transition-all duration-200 - hover:shadow-lg + overflow-hidden rounded-2xl border-2 bg-white transition-all duration-200 + ${table.isActive ? 'cursor-pointer hover:shadow-lg' : 'opacity-75'} ${styles.border} `} > - {/* Body */}
- {/* Number Circle */}
{
- {/* Label */}

Table {table.number}

- {/* Capacity */}
{table.capacity} seats
- {/* Status Badge */}
{ `} > - {table.status} + {displayStatus}
- {/* Actions Footer */}
- - - + {table.isActive ? ( + <> + + + + + ) : ( + + )}
); }; -export default TableCard; \ No newline at end of file +export default TableCard; diff --git a/RestroHub-FrontEnd/src/components/admin/store/tables/TableFormModal.jsx b/RestroHub-FrontEnd/src/components/admin/store/tables/TableFormModal.jsx index 070389a..69d29f4 100644 --- a/RestroHub-FrontEnd/src/components/admin/store/tables/TableFormModal.jsx +++ b/RestroHub-FrontEnd/src/components/admin/store/tables/TableFormModal.jsx @@ -1,26 +1,62 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { X, Loader2, LayoutGrid } from 'lucide-react'; import { Dialog } from '@headlessui/react'; +import api from '@services/common/api'; -const TableFormModal = ({ isOpen, onClose, branchId }) => { - const [formData, setFormData] = useState({ number: '', capacity: '' }); +const TableFormModal = ({ isOpen, onClose, onSaved, branchId, editingTable }) => { + const [formData, setFormData] = useState({ + number: '', + capacity: '', + status: 'available', + }); const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (editingTable) { + setFormData({ + number: editingTable.number || '', + capacity: editingTable.capacity || '', + status: editingTable.status || 'available', + }); + } else { + setFormData({ number: '', capacity: '', status: 'available' }); + } + setError(null); + }, [editingTable, isOpen]); const handleSubmit = async (e) => { e.preventDefault(); + setSubmitting(true); + setError(null); + try { - setSubmitting(true); - // 🔌 API call - await new Promise((r) => setTimeout(r, 500)); + const payload = { + tableNumber: Number(formData.number), + capacity: Number(formData.capacity), + status: formData.status, + }; + + if (editingTable) { + await api.put(`/secure/api/v1/tables/${editingTable.id}`, payload); + } else { + await api.post(`/secure/api/v1/branches/${branchId}/tables`, payload); + } + + onSaved?.(); onClose(); - setFormData({ number: '', capacity: '' }); } catch (err) { console.error('Failed:', err); + setError(err.response?.data?.message || 'Failed to save table'); } finally { setSubmitting(false); } }; + const updateField = (field, value) => { + setFormData((prev) => ({ ...prev, [field]: value })); + }; + const inputClass = ` w-full rounded-lg border border-gray-200 bg-white px-4 py-2.5 text-sm text-gray-900 placeholder-gray-400 @@ -38,14 +74,13 @@ const TableFormModal = ({ isOpen, onClose, branchId }) => { border border-gray-200 bg-white shadow-xl " > - {/* Header */}
- Add New Table + {editingTable ? 'Edit Table' : 'Add New Table'}
- {/* Form */}
+ {error && ( +
+ {error} +
+ )} +
setFormData({ ...formData, number: e.target.value })} + onChange={(e) => updateField('number', e.target.value)} className={inputClass} placeholder="9" required @@ -81,16 +122,30 @@ const TableFormModal = ({ isOpen, onClose, branchId }) => { setFormData({ ...formData, capacity: e.target.value })} + onChange={(e) => updateField('capacity', e.target.value)} className={inputClass} placeholder="4" required />
+
+ + +
- {/* Footer */}
@@ -126,4 +181,4 @@ const TableFormModal = ({ isOpen, onClose, branchId }) => { ); }; -export default TableFormModal; \ No newline at end of file +export default TableFormModal; diff --git a/RestroHub-FrontEnd/src/components/admin/store/tables/TableQRModal.jsx b/RestroHub-FrontEnd/src/components/admin/store/tables/TableQRModal.jsx index 4a62fd1..6516a54 100644 --- a/RestroHub-FrontEnd/src/components/admin/store/tables/TableQRModal.jsx +++ b/RestroHub-FrontEnd/src/components/admin/store/tables/TableQRModal.jsx @@ -8,7 +8,7 @@ const TableQRModal = ({ isOpen, onClose, table, branchId }) => { if (!table) return null; - const qrUrl = `${window.location.origin}/Restrohub/RajkotDhaba/${branchId}?table=${table.number}`; + const qrUrl = `${window.location.origin}/Restrohub/RajkotDhaba/${branchId}?tableId=${table.id}&table=${table.number}`; const handleDownload = async () => { try { @@ -35,7 +35,7 @@ const TableQRModal = ({ isOpen, onClose, table, branchId }) => { {/* Header */}
- Table {table.number} — QR Code + Table {table.number} - QR Code
); }; -export default TablesGrid; \ No newline at end of file +export default TablesGrid; diff --git a/RestroHub-FrontEnd/src/components/admin/store/tables/TablesStatusLegend.jsx b/RestroHub-FrontEnd/src/components/admin/store/tables/TablesStatusLegend.jsx index ebe22f6..c161469 100644 --- a/RestroHub-FrontEnd/src/components/admin/store/tables/TablesStatusLegend.jsx +++ b/RestroHub-FrontEnd/src/components/admin/store/tables/TablesStatusLegend.jsx @@ -1,8 +1,9 @@ const TablesStatusLegend = ({ tables = [] }) => { + const activeTables = tables.filter((table) => table.isActive !== false); const counts = { - available: tables.filter((t) => t.status === 'available').length, - occupied: tables.filter((t) => t.status === 'occupied').length, - reserved: tables.filter((t) => t.status === 'reserved').length, + available: activeTables.filter((t) => t.status === 'available').length, + occupied: activeTables.filter((t) => t.status === 'occupied').length, + reserved: activeTables.filter((t) => t.status === 'reserved').length, }; const statuses = [ @@ -33,4 +34,4 @@ const TablesStatusLegend = ({ tables = [] }) => { ); }; -export default TablesStatusLegend; \ No newline at end of file +export default TablesStatusLegend; diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/branch/dto/BranchResponseDTO.java b/RestroHub/src/main/java/com/restroly/qrmenu/branch/dto/BranchResponseDTO.java index a65b191..2f34166 100644 --- a/RestroHub/src/main/java/com/restroly/qrmenu/branch/dto/BranchResponseDTO.java +++ b/RestroHub/src/main/java/com/restroly/qrmenu/branch/dto/BranchResponseDTO.java @@ -106,8 +106,12 @@ public static class TableDTO { private Integer tableNumber; + private Integer capacity; + + private String status; + private String qrCodeUrl; private Boolean isActive; } -} \ No newline at end of file +} diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/branch/mapper/BranchMapper.java b/RestroHub/src/main/java/com/restroly/qrmenu/branch/mapper/BranchMapper.java index f0b1230..8c0c849 100644 --- a/RestroHub/src/main/java/com/restroly/qrmenu/branch/mapper/BranchMapper.java +++ b/RestroHub/src/main/java/com/restroly/qrmenu/branch/mapper/BranchMapper.java @@ -46,7 +46,7 @@ public BranchResponseDTO toResponseDTO(Branch branch) { .address(toAddressDTO(branch.getAddress())) .menu(toMenuDTO(branch.getMenu())) .tables(toTableDTOList(branch.getTables())) - .tableCount(branch.getTables() != null ? branch.getTables().size() : 0) + .tableCount(countActiveTables(branch.getTables())) .build(); } @@ -61,7 +61,7 @@ public BranchResponseDTO toSummaryDTO(Branch branch) { .isDelete(branch.getIsDelete()) .createdDate(branch.getCreatedDate()) .address(toAddressDTO(branch.getAddress())) - .tableCount(branch.getTables() != null ? branch.getTables().size() : 0) + .tableCount(countActiveTables(branch.getTables())) .build(); } @@ -170,6 +170,8 @@ private BranchResponseDTO.TableDTO toTableDTO(Tables table) { return BranchResponseDTO.TableDTO.builder() .tableId(table.getTableId()) .tableNumber(table.getTableNumber()) + .capacity(table.getCapacity()) + .status(table.getStatus()) .qrCodeUrl(table.getQrCodeUrl()) .isActive(table.getIsActive()) .build(); @@ -182,6 +184,13 @@ private List toTableDTOList(List tables) { .collect(Collectors.toList()); } + private int countActiveTables(List tables) { + if (tables == null) return 0; + return (int) tables.stream() + .filter(table -> Boolean.TRUE.equals(table.getIsActive())) + .count(); + } + private String buildFullAddress(Address address) { StringBuilder sb = new StringBuilder(); @@ -211,4 +220,4 @@ private String buildFullAddress(Address address) { return sb.toString(); } -} \ No newline at end of file +} diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/table/controller/TableController.java b/RestroHub/src/main/java/com/restroly/qrmenu/table/controller/TableController.java new file mode 100644 index 0000000..068d38e --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/table/controller/TableController.java @@ -0,0 +1,136 @@ +package com.restroly.qrmenu.table.controller; + +import com.restroly.qrmenu.common.exception.ErrorResponse; +import com.restroly.qrmenu.common.util.ApiConstants; +import com.restroly.qrmenu.table.dto.TableRequestDTO; +import com.restroly.qrmenu.table.dto.TableResponseDTO; +import com.restroly.qrmenu.table.service.TableService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.net.URI; +import java.util.List; + +import static com.restroly.qrmenu.common.util.ApiConstants.SECURE_API_VERSION; + +@RestController +@RequestMapping(SECURE_API_VERSION) +@RequiredArgsConstructor +@Slf4j +@Validated +@Tag(name = "Table Management", description = "APIs for managing branch tables") +public class TableController { + + private final TableService tableService; + + @GetMapping(value = "/branches/{branchId}/tables", produces = MediaType.APPLICATION_JSON_VALUE) + @Operation(summary = "Get tables by branch", description = "Retrieves all tables for a branch") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Successfully retrieved tables"), + @ApiResponse(responseCode = "404", description = "Branch not found", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public ResponseEntity> getTablesByBranch( + @Parameter(description = "ID of the branch", required = true) + @PathVariable Long branchId) { + + log.debug("REST request to get tables for branch id: {}", branchId); + return ResponseEntity.ok(tableService.getTablesByBranch(branchId)); + } + + @PostMapping(value = "/branches/{branchId}/tables", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasRole('ADMIN') or hasRole('MANAGER')") + @Operation( + summary = "Create a table", + description = "Creates a new table for a branch. Requires ADMIN or MANAGER role.", + security = @SecurityRequirement(name = "bearerAuth") + ) + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Table created successfully", + content = @Content(schema = @Schema(implementation = TableResponseDTO.class))), + @ApiResponse(responseCode = "400", description = "Invalid input data", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "Branch not found", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "409", description = "Duplicate table number", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public ResponseEntity createTable( + @Parameter(description = "ID of the branch", required = true) + @PathVariable Long branchId, + @Valid @RequestBody TableRequestDTO requestDTO) { + + log.info("REST request to create table {} for branch {}", requestDTO.getTableNumber(), branchId); + TableResponseDTO response = tableService.createTable(branchId, requestDTO); + + URI location = URI.create("/" + ApiConstants.APP_NAME + SECURE_API_VERSION + "/tables/" + response.getTableId()); + return ResponseEntity.created(location).body(response); + } + + @PutMapping(value = "/tables/{tableId}", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasRole('ADMIN') or hasRole('MANAGER')") + @Operation( + summary = "Update a table", + description = "Updates table details and status. Requires ADMIN or MANAGER role.", + security = @SecurityRequirement(name = "bearerAuth") + ) + public ResponseEntity updateTable( + @Parameter(description = "ID of the table", required = true) + @PathVariable Long tableId, + @Valid @RequestBody TableRequestDTO requestDTO) { + + log.info("REST request to update table {}", tableId); + return ResponseEntity.ok(tableService.updateTable(tableId, requestDTO)); + } + + @DeleteMapping("/tables/{tableId}") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation( + summary = "Delete a table", + description = "Soft deletes a table. Requires ADMIN role.", + security = @SecurityRequirement(name = "bearerAuth") + ) + public ResponseEntity deleteTable( + @Parameter(description = "ID of the table", required = true) + @PathVariable Long tableId) { + + log.info("REST request to delete table {}", tableId); + tableService.deleteTable(tableId); + return ResponseEntity.noContent().build(); + } + + @PatchMapping(value = "/tables/{tableId}/restore", produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasRole('ADMIN')") + @Operation( + summary = "Restore a table", + description = "Restores a soft-deleted table. Requires ADMIN role.", + security = @SecurityRequirement(name = "bearerAuth") + ) + public ResponseEntity restoreTable( + @Parameter(description = "ID of the table", required = true) + @PathVariable Long tableId) { + + log.info("REST request to restore table {}", tableId); + return ResponseEntity.ok(tableService.restoreTable(tableId)); + } +} diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/table/dto/TableRequestDTO.java b/RestroHub/src/main/java/com/restroly/qrmenu/table/dto/TableRequestDTO.java new file mode 100644 index 0000000..1a9b4d7 --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/table/dto/TableRequestDTO.java @@ -0,0 +1,31 @@ +package com.restroly.qrmenu.table.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "Table creation and update request payload") +public class TableRequestDTO { + + @NotNull(message = "Table number is required") + @Min(value = 1, message = "Table number must be greater than 0") + private Integer tableNumber; + + @Min(value = 1, message = "Capacity must be greater than 0") + private Integer capacity; + + @Pattern(regexp = "available|occupied|reserved", + message = "Status must be available, occupied, or reserved") + private String status; + + private String qrCodeUrl; +} diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/table/dto/TableResponseDTO.java b/RestroHub/src/main/java/com/restroly/qrmenu/table/dto/TableResponseDTO.java new file mode 100644 index 0000000..1a98f45 --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/table/dto/TableResponseDTO.java @@ -0,0 +1,32 @@ +package com.restroly.qrmenu.table.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "Table response payload") +public class TableResponseDTO { + + private Long tableId; + private Long branchId; + private Integer tableNumber; + private Integer capacity; + private String status; + private String qrCodeUrl; + private Boolean isActive; + + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime createdDate; + + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime updatedDate; +} diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/table/entity/Tables.java b/RestroHub/src/main/java/com/restroly/qrmenu/table/entity/Tables.java index f081394..74eaca0 100644 --- a/RestroHub/src/main/java/com/restroly/qrmenu/table/entity/Tables.java +++ b/RestroHub/src/main/java/com/restroly/qrmenu/table/entity/Tables.java @@ -5,6 +5,8 @@ import jakarta.persistence.*; import lombok.*; +import java.time.LocalDateTime; + @Entity @Table(name = "T_table_master") @Getter @@ -26,6 +28,14 @@ public class Tables { @Column(name = "table_number", nullable = false) private Integer tableNumber; + @Column(name = "capacity", nullable = false) + @Builder.Default + private Integer capacity = 4; + + @Column(name = "status", nullable = false, length = 20) + @Builder.Default + private String status = "available"; + @Column(name = "qr_code_url") private String qrCodeUrl; @@ -33,4 +43,29 @@ public class Tables { @Builder.Default private Boolean isActive = true; -} \ No newline at end of file + @Column(name = "created_date") + private LocalDateTime createdDate; + + @Column(name = "updated_date") + private LocalDateTime updatedDate; + + @PrePersist + protected void onCreate() { + createdDate = LocalDateTime.now(); + updatedDate = LocalDateTime.now(); + if (isActive == null) { + isActive = true; + } + if (capacity == null) { + capacity = 4; + } + if (status == null || status.isBlank()) { + status = "available"; + } + } + + @PreUpdate + protected void onUpdate() { + updatedDate = LocalDateTime.now(); + } +} diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/table/repository/TablesRepository.java b/RestroHub/src/main/java/com/restroly/qrmenu/table/repository/TablesRepository.java index 0470985..5310a01 100644 --- a/RestroHub/src/main/java/com/restroly/qrmenu/table/repository/TablesRepository.java +++ b/RestroHub/src/main/java/com/restroly/qrmenu/table/repository/TablesRepository.java @@ -17,5 +17,11 @@ public interface TablesRepository extends JpaRepository { List findByBranch_BranchId(Long branchId); - Tables findByTableNumber(Long tableNumber); -} \ No newline at end of file + Optional findByBranch_BranchIdAndTableNumber(Long branchId, Integer tableNumber); + + boolean existsByBranch_BranchIdAndTableNumber(Long branchId, Integer tableNumber); + + boolean existsByBranch_BranchIdAndTableNumberAndTableIdNot(Long branchId, Integer tableNumber, Long tableId); + + Tables findByTableNumber(Integer tableNumber); +} diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/table/service/TableService.java b/RestroHub/src/main/java/com/restroly/qrmenu/table/service/TableService.java new file mode 100644 index 0000000..5b44454 --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/table/service/TableService.java @@ -0,0 +1,19 @@ +package com.restroly.qrmenu.table.service; + +import com.restroly.qrmenu.table.dto.TableRequestDTO; +import com.restroly.qrmenu.table.dto.TableResponseDTO; + +import java.util.List; + +public interface TableService { + + List getTablesByBranch(Long branchId); + + TableResponseDTO createTable(Long branchId, TableRequestDTO requestDTO); + + TableResponseDTO updateTable(Long tableId, TableRequestDTO requestDTO); + + void deleteTable(Long tableId); + + TableResponseDTO restoreTable(Long tableId); +} diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/table/service/TableServiceImpl.java b/RestroHub/src/main/java/com/restroly/qrmenu/table/service/TableServiceImpl.java new file mode 100644 index 0000000..3a393bc --- /dev/null +++ b/RestroHub/src/main/java/com/restroly/qrmenu/table/service/TableServiceImpl.java @@ -0,0 +1,141 @@ +package com.restroly.qrmenu.table.service; + +import com.restroly.qrmenu.branch.entity.Branch; +import com.restroly.qrmenu.branch.repository.BranchRepository; +import com.restroly.qrmenu.common.exception.ResourceAlreadyExistsException; +import com.restroly.qrmenu.common.exception.ResourceNotFoundException; +import com.restroly.qrmenu.table.dto.TableRequestDTO; +import com.restroly.qrmenu.table.dto.TableResponseDTO; +import com.restroly.qrmenu.table.entity.Tables; +import com.restroly.qrmenu.table.repository.TablesRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional +public class TableServiceImpl implements TableService { + + private static final String TABLE_NOT_FOUND = "Table not found with id: %s"; + + private final TablesRepository tablesRepository; + private final BranchRepository branchRepository; + + @Override + @Transactional(readOnly = true) + public List getTablesByBranch(Long branchId) { + validateBranchExists(branchId); + return tablesRepository.findByBranch_BranchId(branchId) + .stream() + .map(this::toResponseDTO) + .toList(); + } + + @Override + public TableResponseDTO createTable(Long branchId, TableRequestDTO requestDTO) { + log.info("Creating table {} for branch {}", requestDTO.getTableNumber(), branchId); + + Branch branch = branchRepository.findByBranchIdAndIsDeleteFalse(branchId) + .orElseThrow(() -> new ResourceNotFoundException("Branch not found with id: " + branchId)); + + if (tablesRepository.existsByBranch_BranchIdAndTableNumber(branchId, requestDTO.getTableNumber())) { + throw duplicateTableNumber(branchId, requestDTO.getTableNumber()); + } + + Tables table = Tables.builder() + .branch(branch) + .tableNumber(requestDTO.getTableNumber()) + .capacity(requestDTO.getCapacity() != null ? requestDTO.getCapacity() : 4) + .status(normalizeStatus(requestDTO.getStatus())) + .qrCodeUrl(requestDTO.getQrCodeUrl()) + .isActive(true) + .build(); + + return toResponseDTO(tablesRepository.save(table)); + } + + @Override + public TableResponseDTO updateTable(Long tableId, TableRequestDTO requestDTO) { + log.info("Updating table {}", tableId); + + Tables table = findTableOrThrow(tableId); + Long branchId = table.getBranch().getBranchId(); + + if (tablesRepository.existsByBranch_BranchIdAndTableNumberAndTableIdNot( + branchId, requestDTO.getTableNumber(), tableId)) { + throw duplicateTableNumber(branchId, requestDTO.getTableNumber()); + } + + table.setTableNumber(requestDTO.getTableNumber()); + table.setCapacity(requestDTO.getCapacity() != null ? requestDTO.getCapacity() : table.getCapacity()); + table.setStatus(normalizeStatus(requestDTO.getStatus())); + table.setQrCodeUrl(requestDTO.getQrCodeUrl()); + + return toResponseDTO(tablesRepository.save(table)); + } + + @Override + public void deleteTable(Long tableId) { + log.info("Soft deleting table {}", tableId); + + Tables table = findTableOrThrow(tableId); + table.setIsActive(false); + tablesRepository.save(table); + } + + @Override + public TableResponseDTO restoreTable(Long tableId) { + log.info("Restoring table {}", tableId); + + Tables table = findTableOrThrow(tableId); + Long branchId = table.getBranch().getBranchId(); + + tablesRepository.findByBranch_BranchIdAndTableNumber(branchId, table.getTableNumber()) + .filter(existing -> !existing.getTableId().equals(tableId) && Boolean.TRUE.equals(existing.getIsActive())) + .ifPresent(existing -> { + throw duplicateTableNumber(branchId, table.getTableNumber()); + }); + + table.setIsActive(true); + return toResponseDTO(tablesRepository.save(table)); + } + + private void validateBranchExists(Long branchId) { + if (branchRepository.findByBranchIdAndIsDeleteFalse(branchId).isEmpty()) { + throw new ResourceNotFoundException("Branch not found with id: " + branchId); + } + } + + private Tables findTableOrThrow(Long tableId) { + return tablesRepository.findById(tableId) + .orElseThrow(() -> new ResourceNotFoundException(String.format(TABLE_NOT_FOUND, tableId))); + } + + private ResourceAlreadyExistsException duplicateTableNumber(Long branchId, Integer tableNumber) { + return new ResourceAlreadyExistsException( + "Table number " + tableNumber + " already exists for branch id: " + branchId); + } + + private String normalizeStatus(String status) { + return status == null || status.isBlank() ? "available" : status.toLowerCase(); + } + + private TableResponseDTO toResponseDTO(Tables table) { + return TableResponseDTO.builder() + .tableId(table.getTableId()) + .branchId(table.getBranch() != null ? table.getBranch().getBranchId() : null) + .tableNumber(table.getTableNumber()) + .capacity(table.getCapacity()) + .status(table.getStatus()) + .qrCodeUrl(table.getQrCodeUrl()) + .isActive(table.getIsActive()) + .createdDate(table.getCreatedDate()) + .updatedDate(table.getUpdatedDate()) + .build(); + } +} diff --git a/RestroHub/src/test/java/com/restroly/qrmenu/table/service/TableServiceImplTest.java b/RestroHub/src/test/java/com/restroly/qrmenu/table/service/TableServiceImplTest.java new file mode 100644 index 0000000..84e9754 --- /dev/null +++ b/RestroHub/src/test/java/com/restroly/qrmenu/table/service/TableServiceImplTest.java @@ -0,0 +1,270 @@ +package com.restroly.qrmenu.table.service; + +import com.restroly.qrmenu.branch.entity.Branch; +import com.restroly.qrmenu.branch.repository.BranchRepository; +import com.restroly.qrmenu.common.exception.ResourceAlreadyExistsException; +import com.restroly.qrmenu.common.exception.ResourceNotFoundException; +import com.restroly.qrmenu.table.dto.TableRequestDTO; +import com.restroly.qrmenu.table.dto.TableResponseDTO; +import com.restroly.qrmenu.table.entity.Tables; +import com.restroly.qrmenu.table.repository.TablesRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class TableServiceImplTest { + + @Mock + private TablesRepository tablesRepository; + + @Mock + private BranchRepository branchRepository; + + @InjectMocks + private TableServiceImpl tableService; + + @Test + void createTableShouldPersistBranchScopedTable() { + Branch branch = Branch.builder().branchId(1L).build(); + TableRequestDTO request = TableRequestDTO.builder() + .tableNumber(5) + .capacity(4) + .status("available") + .build(); + + when(branchRepository.findByBranchIdAndIsDeleteFalse(1L)).thenReturn(Optional.of(branch)); + when(tablesRepository.existsByBranch_BranchIdAndTableNumber(1L, 5)).thenReturn(false); + when(tablesRepository.save(any(Tables.class))).thenAnswer(invocation -> { + Tables table = invocation.getArgument(0); + table.setTableId(10L); + return table; + }); + + TableResponseDTO response = tableService.createTable(1L, request); + + assertEquals(10L, response.getTableId()); + assertEquals(1L, response.getBranchId()); + assertEquals(5, response.getTableNumber()); + assertTrue(response.getIsActive()); + verify(tablesRepository).save(any(Tables.class)); + } + + @Test + void createTableShouldApplyDefaultsWhenOptionalFieldsAreMissing() { + Branch branch = Branch.builder().branchId(1L).build(); + TableRequestDTO request = TableRequestDTO.builder() + .tableNumber(7) + .build(); + + when(branchRepository.findByBranchIdAndIsDeleteFalse(1L)).thenReturn(Optional.of(branch)); + when(tablesRepository.existsByBranch_BranchIdAndTableNumber(1L, 7)).thenReturn(false); + when(tablesRepository.save(any(Tables.class))).thenAnswer(invocation -> { + Tables table = invocation.getArgument(0); + table.setTableId(11L); + return table; + }); + + TableResponseDTO response = tableService.createTable(1L, request); + + assertEquals(4, response.getCapacity()); + assertEquals("available", response.getStatus()); + assertTrue(response.getIsActive()); + } + + @Test + void createTableShouldRejectMissingBranch() { + TableRequestDTO request = TableRequestDTO.builder().tableNumber(5).build(); + + when(branchRepository.findByBranchIdAndIsDeleteFalse(99L)).thenReturn(Optional.empty()); + + assertThrows(ResourceNotFoundException.class, () -> tableService.createTable(99L, request)); + verify(tablesRepository, never()).save(any(Tables.class)); + } + + @Test + void createTableShouldRejectDuplicateTableNumberInBranch() { + TableRequestDTO request = TableRequestDTO.builder().tableNumber(5).build(); + + when(branchRepository.findByBranchIdAndIsDeleteFalse(1L)) + .thenReturn(Optional.of(Branch.builder().branchId(1L).build())); + when(tablesRepository.existsByBranch_BranchIdAndTableNumber(1L, 5)).thenReturn(true); + + assertThrows(ResourceAlreadyExistsException.class, () -> tableService.createTable(1L, request)); + verify(tablesRepository, never()).save(any(Tables.class)); + } + + @Test + void getTablesByBranchShouldRejectMissingBranch() { + when(branchRepository.findByBranchIdAndIsDeleteFalse(99L)).thenReturn(Optional.empty()); + + assertThrows(ResourceNotFoundException.class, () -> tableService.getTablesByBranch(99L)); + verify(tablesRepository, never()).findByBranch_BranchId(99L); + } + + @Test + void deleteTableShouldDeactivateTable() { + Tables table = Tables.builder() + .tableId(10L) + .branch(Branch.builder().branchId(1L).build()) + .tableNumber(5) + .isActive(true) + .build(); + + when(tablesRepository.findById(10L)).thenReturn(Optional.of(table)); + when(tablesRepository.save(table)).thenReturn(table); + + tableService.deleteTable(10L); + + assertFalse(table.getIsActive()); + verify(tablesRepository).save(table); + } + + @Test + void deleteTableShouldRejectMissingTable() { + when(tablesRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThrows(ResourceNotFoundException.class, () -> tableService.deleteTable(99L)); + verify(tablesRepository, never()).save(any(Tables.class)); + } + + @Test + void updateTableShouldChangeDetailsAndKeepExistingCapacityWhenMissing() { + Branch branch = Branch.builder().branchId(1L).build(); + Tables table = Tables.builder() + .tableId(10L) + .branch(branch) + .tableNumber(5) + .capacity(6) + .status("reserved") + .isActive(true) + .build(); + TableRequestDTO request = TableRequestDTO.builder() + .tableNumber(8) + .status("occupied") + .qrCodeUrl("https://example.com/qr/table-8") + .build(); + + when(tablesRepository.findById(10L)).thenReturn(Optional.of(table)); + when(tablesRepository.existsByBranch_BranchIdAndTableNumberAndTableIdNot(1L, 8, 10L)).thenReturn(false); + when(tablesRepository.save(table)).thenReturn(table); + + TableResponseDTO response = tableService.updateTable(10L, request); + + assertEquals(8, response.getTableNumber()); + assertEquals(6, response.getCapacity()); + assertEquals("occupied", response.getStatus()); + assertEquals("https://example.com/qr/table-8", response.getQrCodeUrl()); + } + + @Test + void updateTableShouldRejectDuplicateTableNumberInBranch() { + Branch branch = Branch.builder().branchId(1L).build(); + Tables table = Tables.builder() + .tableId(10L) + .branch(branch) + .tableNumber(5) + .isActive(true) + .build(); + TableRequestDTO request = TableRequestDTO.builder().tableNumber(8).build(); + + when(tablesRepository.findById(10L)).thenReturn(Optional.of(table)); + when(tablesRepository.existsByBranch_BranchIdAndTableNumberAndTableIdNot(1L, 8, 10L)).thenReturn(true); + + assertThrows(ResourceAlreadyExistsException.class, () -> tableService.updateTable(10L, request)); + verify(tablesRepository, never()).save(any(Tables.class)); + } + + @Test + void updateTableShouldRejectMissingTable() { + TableRequestDTO request = TableRequestDTO.builder().tableNumber(8).build(); + + when(tablesRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThrows(ResourceNotFoundException.class, () -> tableService.updateTable(99L, request)); + } + + @Test + void restoreTableShouldReactivateTable() { + Tables table = Tables.builder() + .tableId(10L) + .branch(Branch.builder().branchId(1L).build()) + .tableNumber(5) + .isActive(false) + .build(); + + when(tablesRepository.findById(10L)).thenReturn(Optional.of(table)); + when(tablesRepository.findByBranch_BranchIdAndTableNumber(1L, 5)).thenReturn(Optional.of(table)); + when(tablesRepository.save(table)).thenReturn(table); + + TableResponseDTO response = tableService.restoreTable(10L); + + assertTrue(response.getIsActive()); + verify(tablesRepository).save(table); + } + + @Test + void restoreTableShouldRejectMissingTable() { + when(tablesRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThrows(ResourceNotFoundException.class, () -> tableService.restoreTable(99L)); + verify(tablesRepository, never()).save(any(Tables.class)); + } + + @Test + void restoreTableShouldRejectActiveDuplicateTableNumber() { + Branch branch = Branch.builder().branchId(1L).build(); + Tables inactiveTable = Tables.builder() + .tableId(10L) + .branch(branch) + .tableNumber(5) + .isActive(false) + .build(); + Tables activeDuplicate = Tables.builder() + .tableId(11L) + .branch(branch) + .tableNumber(5) + .isActive(true) + .build(); + + when(tablesRepository.findById(10L)).thenReturn(Optional.of(inactiveTable)); + when(tablesRepository.findByBranch_BranchIdAndTableNumber(1L, 5)).thenReturn(Optional.of(activeDuplicate)); + + assertThrows(ResourceAlreadyExistsException.class, () -> tableService.restoreTable(10L)); + verify(tablesRepository, never()).save(any(Tables.class)); + } + + @Test + void getTablesByBranchShouldReturnAllBranchTables() { + Branch branch = Branch.builder().branchId(1L).build(); + Tables activeTable = Tables.builder() + .tableId(10L) + .branch(branch) + .tableNumber(1) + .isActive(true) + .build(); + Tables inactiveTable = Tables.builder() + .tableId(11L) + .branch(branch) + .tableNumber(2) + .isActive(false) + .build(); + + when(branchRepository.findByBranchIdAndIsDeleteFalse(1L)).thenReturn(Optional.of(branch)); + when(tablesRepository.findByBranch_BranchId(1L)).thenReturn(List.of(activeTable, inactiveTable)); + + List response = tableService.getTablesByBranch(1L); + + assertEquals(2, response.size()); + assertFalse(response.get(1).getIsActive()); + } +} From a0b30385fc68f0897b7c89af9fa81cc25a59f204 Mon Sep 17 00:00:00 2001 From: Divyanshi Awasthi Date: Thu, 28 May 2026 09:42:12 +0530 Subject: [PATCH 2/2] fix(tables): address PR review feedback --- .../admin/store/tables/TableCard.jsx | 13 +------ .../admin/store/tables/TablesGrid.jsx | 14 +------ .../admin/store/tables/tableMapper.js | 11 ++++++ .../qrmenu/config/SecurityConfig.java | 9 +++-- .../table/controller/TableController.java | 16 ++++---- .../qrmenu/table/dto/TableRequestDTO.java | 2 +- .../table/repository/TablesRepository.java | 5 ++- .../table/service/TableServiceImpl.java | 15 ++++--- .../table/service/TableServiceImplTest.java | 39 ++++++++++++++----- 9 files changed, 70 insertions(+), 54 deletions(-) create mode 100644 RestroHub-FrontEnd/src/components/admin/store/tables/tableMapper.js diff --git a/RestroHub-FrontEnd/src/components/admin/store/tables/TableCard.jsx b/RestroHub-FrontEnd/src/components/admin/store/tables/TableCard.jsx index 009bbc6..2c09cfe 100644 --- a/RestroHub-FrontEnd/src/components/admin/store/tables/TableCard.jsx +++ b/RestroHub-FrontEnd/src/components/admin/store/tables/TableCard.jsx @@ -1,18 +1,7 @@ import { useState } from 'react'; import { QrCode, Edit2, Trash2, Users, Loader2, RotateCcw } from 'lucide-react'; import api from '@services/common/api'; - -const normalizeTable = (table) => ({ - id: table.tableId, - tableId: table.tableId, - branchId: table.branchId, - number: table.tableNumber, - tableNumber: table.tableNumber, - capacity: table.capacity || 4, - status: table.status || 'available', - qrCodeUrl: table.qrCodeUrl, - isActive: table.isActive !== false, -}); +import { normalizeTable } from './tableMapper'; const TableCard = ({ table, onShowQR, onEdit, onDelete, onRestore }) => { const [saving, setSaving] = useState(false); diff --git a/RestroHub-FrontEnd/src/components/admin/store/tables/TablesGrid.jsx b/RestroHub-FrontEnd/src/components/admin/store/tables/TablesGrid.jsx index 3b25b53..4e17394 100644 --- a/RestroHub-FrontEnd/src/components/admin/store/tables/TablesGrid.jsx +++ b/RestroHub-FrontEnd/src/components/admin/store/tables/TablesGrid.jsx @@ -3,18 +3,7 @@ import { RefreshCw, AlertCircle, LayoutGrid } from 'lucide-react'; import api from '@services/common/api'; import TableCard from './TableCard'; import AdminSkeleton from '../../AdminSkeleton'; - -const normalizeTable = (table) => ({ - id: table.tableId, - tableId: table.tableId, - branchId: table.branchId, - number: table.tableNumber, - tableNumber: table.tableNumber, - capacity: table.capacity || 4, - status: table.status || 'available', - qrCodeUrl: table.qrCodeUrl, - isActive: table.isActive !== false, -}); +import { normalizeTable } from './tableMapper'; const TablesGrid = ({ branchId, onShowQR, onEdit, onTablesLoaded, refreshKey }) => { const [tables, setTables] = useState([]); @@ -29,6 +18,7 @@ const TablesGrid = ({ branchId, onShowQR, onEdit, onTablesLoaded, refreshKey }) if (!branchId) { setTables([]); onTablesLoaded?.([]); + setError(null); setLoading(false); return; } diff --git a/RestroHub-FrontEnd/src/components/admin/store/tables/tableMapper.js b/RestroHub-FrontEnd/src/components/admin/store/tables/tableMapper.js new file mode 100644 index 0000000..734fad8 --- /dev/null +++ b/RestroHub-FrontEnd/src/components/admin/store/tables/tableMapper.js @@ -0,0 +1,11 @@ +export const normalizeTable = (table) => ({ + id: table.tableId, + tableId: table.tableId, + branchId: table.branchId, + number: table.tableNumber, + tableNumber: table.tableNumber, + capacity: table.capacity || 4, + status: table.status || 'available', + qrCodeUrl: table.qrCodeUrl, + isActive: table.isActive !== false, +}); diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/config/SecurityConfig.java b/RestroHub/src/main/java/com/restroly/qrmenu/config/SecurityConfig.java index 876dfbd..56787bd 100644 --- a/RestroHub/src/main/java/com/restroly/qrmenu/config/SecurityConfig.java +++ b/RestroHub/src/main/java/com/restroly/qrmenu/config/SecurityConfig.java @@ -62,9 +62,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .authorizeHttpRequests(auth -> auth .requestMatchers(PUBLIC_URLS).permitAll() .requestMatchers(HttpMethod.GET, PUBLIC_GET_URLS).permitAll() - .requestMatchers(HttpMethod.POST, "/secure/api/**").hasAnyRole("ADMIN", "RESTAURANT_OWNER") - .requestMatchers(HttpMethod.PUT, "/secure/api/**").hasAnyRole("ADMIN", "RESTAURANT_OWNER") - .requestMatchers(HttpMethod.DELETE, "/secure/api/**").hasAnyRole("ADMIN", "RESTAURANT_OWNER") + .requestMatchers(HttpMethod.POST, "/secure/api/**").hasAnyRole("ADMIN", "MANAGER", "RESTAURANT_OWNER") + .requestMatchers(HttpMethod.PUT, "/secure/api/**").hasAnyRole("ADMIN", "MANAGER", "RESTAURANT_OWNER") + .requestMatchers(HttpMethod.PATCH, "/secure/api/**").hasAnyRole("ADMIN", "MANAGER", "RESTAURANT_OWNER") + .requestMatchers(HttpMethod.DELETE, "/secure/api/**").hasAnyRole("ADMIN", "MANAGER", "RESTAURANT_OWNER") // All other requests require authentication .anyRequest().authenticated() ) @@ -86,4 +87,4 @@ public AuthenticationManager authenticationManager( AuthenticationConfiguration authConfig) throws Exception { return authConfig.getAuthenticationManager(); } -} \ No newline at end of file +} diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/table/controller/TableController.java b/RestroHub/src/main/java/com/restroly/qrmenu/table/controller/TableController.java index 068d38e..80e5ae9 100644 --- a/RestroHub/src/main/java/com/restroly/qrmenu/table/controller/TableController.java +++ b/RestroHub/src/main/java/com/restroly/qrmenu/table/controller/TableController.java @@ -56,10 +56,10 @@ public ResponseEntity> getTablesByBranch( @PostMapping(value = "/branches/{branchId}/tables", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) - @PreAuthorize("hasRole('ADMIN') or hasRole('MANAGER')") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'RESTAURANT_OWNER')") @Operation( summary = "Create a table", - description = "Creates a new table for a branch. Requires ADMIN or MANAGER role.", + description = "Creates a new table for a branch. Requires ADMIN, MANAGER, or RESTAURANT_OWNER role.", security = @SecurityRequirement(name = "bearerAuth") ) @ApiResponses({ @@ -87,10 +87,10 @@ public ResponseEntity createTable( @PutMapping(value = "/tables/{tableId}", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) - @PreAuthorize("hasRole('ADMIN') or hasRole('MANAGER')") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'RESTAURANT_OWNER')") @Operation( summary = "Update a table", - description = "Updates table details and status. Requires ADMIN or MANAGER role.", + description = "Updates table details and status. Requires ADMIN, MANAGER, or RESTAURANT_OWNER role.", security = @SecurityRequirement(name = "bearerAuth") ) public ResponseEntity updateTable( @@ -103,11 +103,11 @@ public ResponseEntity updateTable( } @DeleteMapping("/tables/{tableId}") - @PreAuthorize("hasRole('ADMIN')") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'RESTAURANT_OWNER')") @ResponseStatus(HttpStatus.NO_CONTENT) @Operation( summary = "Delete a table", - description = "Soft deletes a table. Requires ADMIN role.", + description = "Soft deletes a table. Requires ADMIN, MANAGER, or RESTAURANT_OWNER role.", security = @SecurityRequirement(name = "bearerAuth") ) public ResponseEntity deleteTable( @@ -120,10 +120,10 @@ public ResponseEntity deleteTable( } @PatchMapping(value = "/tables/{tableId}/restore", produces = MediaType.APPLICATION_JSON_VALUE) - @PreAuthorize("hasRole('ADMIN')") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'RESTAURANT_OWNER')") @Operation( summary = "Restore a table", - description = "Restores a soft-deleted table. Requires ADMIN role.", + description = "Restores a soft-deleted table. Requires ADMIN, MANAGER, or RESTAURANT_OWNER role.", security = @SecurityRequirement(name = "bearerAuth") ) public ResponseEntity restoreTable( diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/table/dto/TableRequestDTO.java b/RestroHub/src/main/java/com/restroly/qrmenu/table/dto/TableRequestDTO.java index 1a9b4d7..c7110a8 100644 --- a/RestroHub/src/main/java/com/restroly/qrmenu/table/dto/TableRequestDTO.java +++ b/RestroHub/src/main/java/com/restroly/qrmenu/table/dto/TableRequestDTO.java @@ -23,7 +23,7 @@ public class TableRequestDTO { @Min(value = 1, message = "Capacity must be greater than 0") private Integer capacity; - @Pattern(regexp = "available|occupied|reserved", + @Pattern(regexp = "(?i)available|occupied|reserved", message = "Status must be available, occupied, or reserved") private String status; diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/table/repository/TablesRepository.java b/RestroHub/src/main/java/com/restroly/qrmenu/table/repository/TablesRepository.java index 5310a01..c601dce 100644 --- a/RestroHub/src/main/java/com/restroly/qrmenu/table/repository/TablesRepository.java +++ b/RestroHub/src/main/java/com/restroly/qrmenu/table/repository/TablesRepository.java @@ -17,11 +17,12 @@ public interface TablesRepository extends JpaRepository { List findByBranch_BranchId(Long branchId); - Optional findByBranch_BranchIdAndTableNumber(Long branchId, Integer tableNumber); - boolean existsByBranch_BranchIdAndTableNumber(Long branchId, Integer tableNumber); boolean existsByBranch_BranchIdAndTableNumberAndTableIdNot(Long branchId, Integer tableNumber, Long tableId); + boolean existsByBranch_BranchIdAndTableNumberAndIsActiveTrueAndTableIdNot( + Long branchId, Integer tableNumber, Long tableId); + Tables findByTableNumber(Integer tableNumber); } diff --git a/RestroHub/src/main/java/com/restroly/qrmenu/table/service/TableServiceImpl.java b/RestroHub/src/main/java/com/restroly/qrmenu/table/service/TableServiceImpl.java index 3a393bc..061cb07 100644 --- a/RestroHub/src/main/java/com/restroly/qrmenu/table/service/TableServiceImpl.java +++ b/RestroHub/src/main/java/com/restroly/qrmenu/table/service/TableServiceImpl.java @@ -73,7 +73,7 @@ public TableResponseDTO updateTable(Long tableId, TableRequestDTO requestDTO) { table.setTableNumber(requestDTO.getTableNumber()); table.setCapacity(requestDTO.getCapacity() != null ? requestDTO.getCapacity() : table.getCapacity()); - table.setStatus(normalizeStatus(requestDTO.getStatus())); + table.setStatus(hasText(requestDTO.getStatus()) ? normalizeStatus(requestDTO.getStatus()) : table.getStatus()); table.setQrCodeUrl(requestDTO.getQrCodeUrl()); return toResponseDTO(tablesRepository.save(table)); @@ -95,11 +95,10 @@ public TableResponseDTO restoreTable(Long tableId) { Tables table = findTableOrThrow(tableId); Long branchId = table.getBranch().getBranchId(); - tablesRepository.findByBranch_BranchIdAndTableNumber(branchId, table.getTableNumber()) - .filter(existing -> !existing.getTableId().equals(tableId) && Boolean.TRUE.equals(existing.getIsActive())) - .ifPresent(existing -> { - throw duplicateTableNumber(branchId, table.getTableNumber()); - }); + if (tablesRepository.existsByBranch_BranchIdAndTableNumberAndIsActiveTrueAndTableIdNot( + branchId, table.getTableNumber(), tableId)) { + throw duplicateTableNumber(branchId, table.getTableNumber()); + } table.setIsActive(true); return toResponseDTO(tablesRepository.save(table)); @@ -125,6 +124,10 @@ private String normalizeStatus(String status) { return status == null || status.isBlank() ? "available" : status.toLowerCase(); } + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } + private TableResponseDTO toResponseDTO(Tables table) { return TableResponseDTO.builder() .tableId(table.getTableId()) diff --git a/RestroHub/src/test/java/com/restroly/qrmenu/table/service/TableServiceImplTest.java b/RestroHub/src/test/java/com/restroly/qrmenu/table/service/TableServiceImplTest.java index 84e9754..f7f3725 100644 --- a/RestroHub/src/test/java/com/restroly/qrmenu/table/service/TableServiceImplTest.java +++ b/RestroHub/src/test/java/com/restroly/qrmenu/table/service/TableServiceImplTest.java @@ -166,6 +166,32 @@ void updateTableShouldChangeDetailsAndKeepExistingCapacityWhenMissing() { assertEquals("https://example.com/qr/table-8", response.getQrCodeUrl()); } + @Test + void updateTableShouldKeepExistingStatusWhenMissing() { + Branch branch = Branch.builder().branchId(1L).build(); + Tables table = Tables.builder() + .tableId(10L) + .branch(branch) + .tableNumber(5) + .capacity(6) + .status("reserved") + .isActive(true) + .build(); + TableRequestDTO request = TableRequestDTO.builder() + .tableNumber(5) + .capacity(8) + .build(); + + when(tablesRepository.findById(10L)).thenReturn(Optional.of(table)); + when(tablesRepository.existsByBranch_BranchIdAndTableNumberAndTableIdNot(1L, 5, 10L)).thenReturn(false); + when(tablesRepository.save(table)).thenReturn(table); + + TableResponseDTO response = tableService.updateTable(10L, request); + + assertEquals(8, response.getCapacity()); + assertEquals("reserved", response.getStatus()); + } + @Test void updateTableShouldRejectDuplicateTableNumberInBranch() { Branch branch = Branch.builder().branchId(1L).build(); @@ -203,7 +229,8 @@ void restoreTableShouldReactivateTable() { .build(); when(tablesRepository.findById(10L)).thenReturn(Optional.of(table)); - when(tablesRepository.findByBranch_BranchIdAndTableNumber(1L, 5)).thenReturn(Optional.of(table)); + when(tablesRepository.existsByBranch_BranchIdAndTableNumberAndIsActiveTrueAndTableIdNot(1L, 5, 10L)) + .thenReturn(false); when(tablesRepository.save(table)).thenReturn(table); TableResponseDTO response = tableService.restoreTable(10L); @@ -229,15 +256,9 @@ void restoreTableShouldRejectActiveDuplicateTableNumber() { .tableNumber(5) .isActive(false) .build(); - Tables activeDuplicate = Tables.builder() - .tableId(11L) - .branch(branch) - .tableNumber(5) - .isActive(true) - .build(); - when(tablesRepository.findById(10L)).thenReturn(Optional.of(inactiveTable)); - when(tablesRepository.findByBranch_BranchIdAndTableNumber(1L, 5)).thenReturn(Optional.of(activeDuplicate)); + when(tablesRepository.existsByBranch_BranchIdAndTableNumberAndIsActiveTrueAndTableIdNot(1L, 5, 10L)) + .thenReturn(true); assertThrows(ResourceAlreadyExistsException.class, () -> tableService.restoreTable(10L)); verify(tablesRepository, never()).save(any(Tables.class));