Bugfix/testing (#4)
Build Docker Image / build-backend (push) Successful in 58s
Build Docker Image / build-admin (push) Successful in 6s
Build Docker Image / build-teller (push) Successful in 8s
Build Docker Image / build-customer (push) Successful in 2m6s

Co-authored-by: ISMAIL MASSERAN <topaz@Mac.dlinkrouter.local>
Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local>
Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-07-30 16:22:51 +08:00
parent 318e0c3eb4
commit 0420194541
9 changed files with 179 additions and 117 deletions
+2
View File
@@ -0,0 +1,2 @@
[x] create qr and teller account
[x] increase size of gold display
+1 -30
View File
@@ -4,7 +4,7 @@ services:
container_name: qms-mysql
restart: unless-stopped
ports:
- "${MYSQL_HOST_PORT:-3307}:3306"
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-password}
MYSQL_DATABASE: ${MYSQL_DATABASE:-qms}
@@ -27,37 +27,8 @@ services:
networks:
- qms-net
backend:
image: ${BACKEND_IMAGE:-qms-backend}:${IMAGE_TAG:-local}
build:
context: .
dockerfile: Dockerfile
container_name: qms-backend
restart: unless-stopped
ports:
# Host port can be changed via BACKEND_HOST_PORT in .env (default 8080)
- "${BACKEND_HOST_PORT:-8080}:8080"
environment:
SERVER_PORT: 8080
# Use Docker service name "mysql", not localhost
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/${MYSQL_DATABASE:-qms}?allowPublicKeyRetrieval=true&useSSL=false
SPRING_DATASOURCE_USERNAME: root
SPRING_DATASOURCE_PASSWORD: ${MYSQL_ROOT_PASSWORD:-password}
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-a68uiaDQ0V3iLjF4DqMuS13GAVwkut55dlFbGCLyXTF}
ADS_UPLOAD_DIR: /app/uploads/ads
NOTIFICATIONS_MOCK: ${NOTIFICATIONS_MOCK:-true}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-dummy-google-client-id}
volumes:
- qms-uploads:/app/uploads
depends_on:
mysql:
condition: service_healthy
networks:
- qms-net
volumes:
qms-mysql-data:
qms-uploads:
networks:
qms-net:
@@ -33,6 +33,7 @@ public class DefaultAdminService implements AdminService {
private final RoleService roleService;
private final PasswordEncoder passwordEncoder;
private final AuthService authService;
public DefaultAdminService(final UserRepository userRepository,
final UserService userService,
final TwoFactorService twoFactorService,
@@ -50,7 +51,7 @@ public class DefaultAdminService implements AdminService {
}
@Override
public List<User> findUsersByCode(final String tenantCode, final String roleName){
public List<User> findUsersByCode(final String tenantCode, final String roleName) {
final Set<RoleName> roleNameSet = Set.of(RoleName.valueOf(roleName));
return this.userRepository.findAllByTenant_CodeAndRoles_NameIn(tenantCode, roleNameSet);
}
@@ -14,6 +14,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@@ -30,7 +31,8 @@ public class AdminController {
@PostMapping("/{code}")
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')")
public ResponseEntity getUsers(@RequestBody RoleRequest request, @PathVariable(name = "code") final String tenantCode) {
public ResponseEntity<List<UserDto>> getUsers(@RequestBody RoleRequest request,
@PathVariable(name = "code") final String tenantCode) {
RoleName roleName;
try {
roleName = RoleName.valueOf(request.roleName);
@@ -39,7 +41,7 @@ public class AdminController {
return ResponseEntity.badRequest().build();
}
if(this.authService.canOnlyCRUDUser(roleName)){
if (this.authService.canOnlyCRUDUser(roleName)) {
logger.warn("Only super admin can read admins");
return ResponseEntity.badRequest().build();
}
@@ -52,8 +54,7 @@ public class AdminController {
return ResponseEntity.ok().body(
this.adminService.findUsersByCode(tenantCode, request.roleName).stream()
.map(UserDto::fromEntity)
.collect(Collectors.toList())
);
.collect(Collectors.toList()));
} catch (EntityNotFoundException e) {
return ResponseEntity.badRequest().build();
}
@@ -61,7 +62,8 @@ public class AdminController {
@PostMapping("/{code}/user")
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')")
public ResponseEntity addUser(@RequestBody final AdminRequest request, @PathVariable(name = "code") final String tenantCode) throws AuthException {
public ResponseEntity<UserDto> addUser(@RequestBody final AdminRequest request,
@PathVariable(name = "code") final String tenantCode) throws AuthException {
RoleName roleName;
try {
roleName = RoleName.valueOf(request.roleName);
@@ -70,7 +72,7 @@ public class AdminController {
return ResponseEntity.badRequest().build();
}
if(this.authService.canOnlyCRUDUser(roleName)){
if (this.authService.canOnlyCRUDUser(roleName)) {
logger.warn("Only super admin can add admin");
return ResponseEntity.badRequest().build();
}
@@ -95,7 +97,8 @@ public class AdminController {
@PutMapping("/{code}/user/{userId}")
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')")
public ResponseEntity updateAdmin(@RequestBody final UserDto request, @PathVariable(name = "code") final String tenantCode, @PathVariable(name = "userId") final long adminId) {
public ResponseEntity<SimpleMessageDto> updateAdmin(@RequestBody final UserDto request,
@PathVariable(name = "code") final String tenantCode, @PathVariable(name = "userId") final long adminId) {
if (!this.authService.canChangeTenant(tenantCode)) {
logger.warn("Admin does not belong to the specified tenant");
return ResponseEntity.badRequest().build();
@@ -112,7 +115,8 @@ public class AdminController {
@DeleteMapping("/{code}/user/{userId}")
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')")
public ResponseEntity removeAdmin(@PathVariable(name = "code") final String tenantCode, @PathVariable(name = "userId") final long adminId) {
public ResponseEntity<SimpleMessageDto> removeAdmin(@PathVariable(name = "code") final String tenantCode,
@PathVariable(name = "userId") final long adminId) {
if (!this.authService.canChangeTenant(tenantCode)) {
logger.warn("Admin does not belong to the specified tenant");
return ResponseEntity.badRequest().build();
@@ -130,6 +134,6 @@ public class AdminController {
public record AdminRequest(String email, String password, String roleName) {
}
public record RoleRequest(String roleName){
public record RoleRequest(String roleName) {
}
}
@@ -36,7 +36,7 @@ public class AdvertisementController {
}
@GetMapping("/media/{adId}")
public ResponseEntity streamMedia(@PathVariable final long adId) {
public ResponseEntity<Resource> streamMedia(@PathVariable final long adId) {
try {
final Advertisement advertisement = this.advertisementService.findById(adId);
final Resource resource = this.advertisementService.loadMedia(adId);
@@ -80,8 +80,7 @@ public class AdvertisementController {
title,
durationSeconds,
sortOrder,
active
);
active);
return ResponseEntity.ok().body(AdvertisementDto.fromEntity(created));
} catch (final Exception exception) {
return ResponseEntity.badRequest().body(new SimpleMessageDto(exception.getMessage()));
@@ -121,8 +120,7 @@ public class AdvertisementController {
request.title(),
request.durationSeconds(),
request.sortOrder(),
request.active()
);
request.active());
return ResponseEntity.ok().body(AdvertisementDto.fromEntity(updated));
} catch (final Exception exception) {
return ResponseEntity.badRequest().body(new SimpleMessageDto(exception.getMessage()));
@@ -22,6 +22,9 @@ type Props = {
initialBranchId?: number;
};
/** Prevents spam of "Dapatkan tiket lain" right after getting a number. */
const ANOTHER_TICKET_COOLDOWN_MS = 10_000;
function pickLatestTicket(
tickets: Ticket[],
branchId?: number
@@ -61,8 +64,27 @@ export default function CustomerTicketFlow({
const [ticket, setTicket] = useState<Ticket | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [cooldownLeftMs, setCooldownLeftMs] = useState(0);
const bootstrapped = useRef(false);
useEffect(() => {
if (step !== "ticket" || !ticket) {
setCooldownLeftMs(0);
return;
}
const tick = () => {
const elapsed = Date.now() - new Date(ticket.createdAt).getTime();
setCooldownLeftMs(
Math.max(0, ANOTHER_TICKET_COOLDOWN_MS - elapsed)
);
};
tick();
const id = window.setInterval(tick, 250);
return () => window.clearInterval(id);
}, [step, ticket]);
useEffect(() => {
if (bootstrapped.current) return;
bootstrapped.current = true;
@@ -281,6 +303,9 @@ export default function CustomerTicketFlow({
? "Pilih perkhidmatan untuk mendapatkan nombor."
: "Imbas QR code di branch anda, atau masukkan kod syarikat di bawah.";
const canGetAnotherTicket = cooldownLeftMs <= 0;
const cooldownSeconds = Math.ceil(cooldownLeftMs / 1000);
return (
<main className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-8 px-6 py-12">
<header className="space-y-2">
@@ -423,7 +448,7 @@ export default function CustomerTicketFlow({
{step === "ticket" && ticket ? (
<section className="space-y-6 rounded-md border border-zinc-200 bg-white px-6 py-8 text-center">
<p className="text-sm tracking-wide text-zinc-500 uppercase">
Nombor tiket anda
Nombor giliran anda
</p>
<p className="text-6xl font-semibold tracking-tight text-zinc-900">
{ticket.number}
@@ -432,12 +457,36 @@ export default function CustomerTicketFlow({
<p>{ticket.service.name}</p>
<p>{ticket.branch.name}</p>
</div>
<div
className="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-left text-sm text-amber-950"
role="status"
>
<p className="font-semibold">Anda sudah mempunyai nombor giliran.</p>
<p className="mt-1 text-amber-900">
Sila tunggu sehingga nombor anda dipanggil.
</p>
{!canGetAnotherTicket ? (
<p className="mt-2 font-medium tabular-nums text-amber-950">
Tiket lain boleh diambil dalam {cooldownSeconds}s
</p>
) : null}
</div>
<button
type="button"
onClick={resetFlow}
className="w-full rounded-md bg-zinc-900 px-4 py-3 text-sm font-medium text-white"
disabled={!canGetAnotherTicket || loading}
aria-disabled={!canGetAnotherTicket || loading}
className={
canGetAnotherTicket
? "w-full rounded-md border border-zinc-300 bg-white px-4 py-3 text-sm font-medium text-zinc-700 transition hover:border-zinc-500 hover:bg-zinc-50 disabled:opacity-60"
: "w-full cursor-not-allowed rounded-md bg-zinc-200 px-4 py-3 text-sm font-medium text-zinc-500"
}
>
Dapatkan tiket lain
{canGetAnotherTicket
? "Dapatkan nombor giliran lain"
: `Tunggu ${cooldownSeconds}s…`}
</button>
</section>
) : null}
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "next dev --webpack",
"build": "next build",
"start": "next start",
"lint": "eslint"
@@ -15,11 +15,15 @@
--bd-error-bg: #5c1a1a;
--bd-error-text: #fecaca;
/* Fluid root: scales 720p → 4K; descendants use em so they follow */
font-size: clamp(12px, 0.75vw + 0.55vh, 28px);
position: relative;
height: 100vh;
height: 100dvh;
width: 100%;
box-sizing: border-box;
padding: 1rem 1.5rem 1rem;
padding: 0.75em 1em;
background:
radial-gradient(ellipse 80% 50% at 10% -10%, rgba(62, 207, 122, 0.12), transparent 55%),
radial-gradient(ellipse 60% 40% at 95% 5%, rgba(240, 208, 96, 0.1), transparent 50%),
@@ -28,7 +32,7 @@
font-family: system-ui, -apple-system, Segoe UI, sans-serif;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 0.75rem;
gap: 0.6em;
overflow: hidden;
}
@@ -37,28 +41,28 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
gap: 1em;
}
.branch-display__brand {
display: flex;
align-items: center;
gap: 0.85rem;
gap: 0.85em;
min-width: 0;
}
.branch-display__logo {
height: clamp(2.5rem, 4.5vw, 3.5rem);
height: clamp(2em, 1.8em + 1vw, 4em);
width: auto;
flex-shrink: 0;
border-radius: 0.35rem;
border-radius: 0.35em;
object-fit: contain;
background: #fff;
}
.branch-display__eyebrow {
margin: 0;
font-size: 0.75rem;
font-size: 0.75em;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--bd-accent);
@@ -67,7 +71,7 @@
.branch-display__title {
margin: 0;
font-size: clamp(1.4rem, 2.2vw, 2rem);
font-size: clamp(1.25em, 2vw, 1.75em);
font-weight: 700;
letter-spacing: -0.02em;
color: var(--bd-text);
@@ -76,17 +80,17 @@
.branch-display__error {
margin: 0;
padding: 0.5rem 0.75rem;
border-radius: 0.4rem;
padding: 0.5em 0.75em;
border-radius: 0.4em;
background: var(--bd-error-bg);
color: var(--bd-error-text);
font-size: 0.9rem;
font-size: 0.9em;
}
.branch-display__main {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(340px, 1.05fr);
gap: 0.85rem;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
gap: 0.85em;
min-height: 0;
overflow: hidden;
}
@@ -94,6 +98,7 @@
@media (max-width: 960px) {
.branch-display {
height: auto;
min-height: 100dvh;
min-height: 100vh;
overflow: auto;
grid-template-rows: auto;
@@ -110,7 +115,7 @@
.branch-display__section--gold {
flex: none;
min-height: 16rem;
min-height: 16em;
}
}
@@ -119,7 +124,7 @@
min-height: 0;
display: flex;
flex-direction: column;
gap: 0.65rem;
gap: 0.75em;
overflow: hidden;
}
@@ -128,12 +133,23 @@
min-height: 0;
}
/* Serving block must keep its content height — otherwise cards spill into Giliran */
.branch-display__queue-column > .branch-display__section:first-of-type {
flex: 0 0 auto;
min-height: auto;
overflow: hidden;
}
.branch-display__section--waiting {
flex: 0 1 auto;
max-height: 28%;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
position: relative;
z-index: 1;
background: transparent;
}
.branch-display__section--gold {
@@ -141,11 +157,12 @@
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.branch-display__section-title {
margin: 0 0 0.45rem;
font-size: 0.85rem;
margin: 0 0 0.45em;
font-size: 1.05em;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
@@ -154,16 +171,16 @@
.branch-display__stations {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.55rem;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 10em), 1fr));
gap: 0.7em;
}
.branch-display__station-card {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.65rem 0.5rem;
border-radius: 0.5rem;
gap: 0.3em;
padding: 0.9em 0.65em;
border-radius: 0.5em;
background: linear-gradient(160deg, var(--bd-surface-raised) 0%, var(--bd-surface) 100%);
border: 1px solid var(--bd-border);
box-shadow: inset 0 1px 0 rgba(62, 207, 122, 0.08);
@@ -172,18 +189,38 @@
.branch-display__station-name {
margin: 0;
font-size: 0.8rem;
font-size: 1.1em;
color: var(--bd-muted);
}
.branch-display__ticket-number {
margin: 0;
font-size: clamp(1.75rem, 3.2vw, 2.75rem);
font-size: clamp(1.75em, min(4.2vw, 8vh), 5.5em);
font-weight: 700;
line-height: 1;
letter-spacing: -0.03em;
color: var(--bd-accent-bright);
text-shadow: 0 0 24px rgba(255, 229, 102, 0.25);
text-shadow: 0 0 1.5em rgba(255, 229, 102, 0.25);
}
/* Short TVs (e.g. 720p): tighten serving cards so sections don't collide */
@media (max-height: 800px) {
.branch-display__station-card {
padding: 0.55em 0.5em;
gap: 0.15em;
}
.branch-display__ticket-number {
font-size: clamp(1.5em, min(3.6vw, 7vh), 3.25em);
}
.branch-display__section-title {
margin-bottom: 0.3em;
}
.branch-display__section--waiting {
max-height: 24%;
}
}
.branch-display__ticket-number--idle {
@@ -193,7 +230,7 @@
.branch-display__station-service {
margin: 0;
font-size: 0.75rem;
font-size: 1em;
color: var(--bd-muted-strong);
white-space: nowrap;
overflow: hidden;
@@ -204,8 +241,8 @@
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.4rem;
gap: 0.75em;
margin-bottom: 0.4em;
flex-shrink: 0;
}
@@ -215,14 +252,14 @@
.branch-display__waiting-count {
margin: 0;
font-size: 0.85rem;
font-size: 1.05em;
color: var(--bd-accent);
}
.branch-display__empty {
margin: 0;
color: var(--bd-muted);
font-size: 0.9rem;
font-size: 1.05em;
}
.branch-display__waiting-list {
@@ -231,7 +268,7 @@
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
gap: 0.45em;
flex: 1;
min-height: 0;
overflow: auto;
@@ -241,24 +278,24 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.4rem 0.7rem;
border-radius: 0.4rem;
gap: 0.75em;
padding: 0.55em 0.85em;
border-radius: 0.4em;
background: var(--bd-surface);
border: 1px solid var(--bd-border);
border-left: 3px solid var(--bd-green);
border-left: 0.2em solid var(--bd-green);
flex-shrink: 0;
}
.branch-display__waiting-number {
font-size: clamp(1.1rem, 1.8vw, 1.5rem);
font-size: clamp(1.35em, 2.4vw, 2.5em);
font-weight: 700;
letter-spacing: -0.02em;
color: var(--bd-accent);
}
.branch-display__waiting-service {
font-size: 0.85rem;
font-size: 0.85em;
color: var(--bd-muted-strong);
}
@@ -266,8 +303,8 @@
min-width: 0;
min-height: 0;
height: 100%;
padding: 0.65rem;
border-radius: 0.6rem;
padding: 0.65em;
border-radius: 0.6em;
background: linear-gradient(160deg, var(--bd-surface-raised) 0%, var(--bd-surface) 100%);
border: 1px solid var(--bd-border);
box-shadow: inset 0 1px 0 rgba(240, 208, 96, 0.06);
@@ -280,8 +317,8 @@
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.4rem;
gap: 0.75em;
margin-bottom: 0.4em;
flex-shrink: 0;
}
@@ -294,7 +331,7 @@
flex: 1;
min-height: 0;
width: 100%;
border-radius: 0.4rem;
border-radius: 0.4em;
overflow: hidden;
background: var(--bd-bg);
border: 1px solid var(--bd-border);
@@ -309,8 +346,8 @@
}
.branch-display__ad-caption {
margin: 0.35rem 0 0;
font-size: 0.8rem;
margin: 0.35em 0 0;
font-size: 0.8em;
color: var(--bd-muted-strong);
text-align: center;
flex-shrink: 0;
@@ -320,7 +357,7 @@
}
.branch-display__section--gold .branch-display__waiting-header {
margin-bottom: 0.35rem;
margin-bottom: 0.35em;
}
.branch-display__gold-table-wrap {
@@ -328,7 +365,7 @@
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
border-radius: 0.5rem;
border-radius: 0.5em;
border: 1px solid var(--bd-border);
background: var(--bd-surface);
scrollbar-width: none;
@@ -341,19 +378,19 @@
.branch-display__gold-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
font-size: 1.25em;
}
.branch-display__gold-table th,
.branch-display__gold-table td {
padding: 0.45rem 0.65rem;
padding: 0.55em 0.75em;
text-align: left;
border-bottom: 1px solid var(--bd-border);
line-height: 1.3;
}
.branch-display__gold-table th {
font-size: 0.7rem;
font-size: 0.72em;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--bd-accent);
@@ -236,7 +236,7 @@ export default function BranchDisplayPage() {
{ticket ? ticket.number : '—'}
</p>
<p className="branch-display__station-service">
{ticket?.service?.name ?? 'Waiting for next'}
{ticket?.service?.name ?? 'Menunggu nombor giliran berikutnya'}
</p>
</article>
);