From b794c19326c1782e4ce3772962075111af605bf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Sikl=C3=B3di?= Date: Wed, 11 Nov 2026 21:05:32 +0100 Subject: [PATCH 1/3] =?UTF-8?q?feat(api,web,shared):=20TICKET-40=20bel?= =?UTF-8?q?=C3=A9p=C3=A9sek=20a=20vend=C3=A9gadatlapon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shared: új GuestArrivalDto és GuestDto.arrivals mező; közös toGuestArrivals leképezés (a beléptetési naplóból: sikeres, sztornózott, elutasított; a dupla beolvasás nem belépés; legújabb elöl, legfeljebb 100). Az API és a mock is ezt hívja. - api: a vendég lekérése a logged_check_ins sorait is betölti (rögzítő, bérlet, levonás szolgáltatása), és arrivals-ként adja. - web mock: a vendég belépései ugyanígy a mock beléptetési naplójából. - web: új „Belépések” fül (időpont Europe/Budapest szerint, köredzés vagy kezelés a szolgáltatással, bérlet, rögzítő, állapot és ok, „Továbbiak” bővítés); a Bérletek fülön bérletenként lenyitható a bérlethez kötött belépések listája. - tesztek: guests.service.spec (leképezés, include), web unit (cím, időzóna, bérlet szerinti szűrés, közös leképezés). - Újdonságok bejegyzés (IMPROVEMENT, TICKET-40). Co-Authored-By: Claude Opus 5.5 --- .../src/modules/guests/guests.service.spec.ts | 220 ++++++++++++++++++ apps/api/src/modules/guests/guests.service.ts | 33 +++ .../src/pages/reception/model/releaseNotes.ts | 14 ++ apps/web/src/shared/api/mock-engine.ts | 37 +++ .../guest-profile/model/arrivals.test.ts | 105 +++++++++ .../widgets/guest-profile/model/arrivals.ts | 55 +++++ .../guest-profile/ui/GuestArrivals.tsx | 154 ++++++++++++ .../widgets/guest-profile/ui/GuestModal.tsx | 20 ++ packages/shared/src/contracts/index.ts | 29 +++ packages/shared/src/guest-arrivals.ts | 76 ++++++ packages/shared/src/index.ts | 1 + 11 files changed, 744 insertions(+) create mode 100644 apps/api/src/modules/guests/guests.service.spec.ts create mode 100644 apps/web/src/widgets/guest-profile/model/arrivals.test.ts create mode 100644 apps/web/src/widgets/guest-profile/model/arrivals.ts create mode 100644 apps/web/src/widgets/guest-profile/ui/GuestArrivals.tsx create mode 100644 packages/shared/src/guest-arrivals.ts diff --git a/apps/api/src/modules/guests/guests.service.spec.ts b/apps/api/src/modules/guests/guests.service.spec.ts new file mode 100644 index 0000000..6552a3f --- /dev/null +++ b/apps/api/src/modules/guests/guests.service.spec.ts @@ -0,0 +1,220 @@ +import { + CheckInStatus, + GUEST_ARRIVALS_LIMIT, + MEMBERSHIP_ARRIVAL_PASS_NAME, + VisitType, +} from '@reform/shared'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { GuestsService } from './guests.service'; + +const STUDIO = 'default-studio'; +const staff = { id: 'staff-1', name: 'Kiss Réka' }; +const hypoxiPass = { + id: 'pass-hypoxi', + studioId: STUDIO, + guestId: 'guest-1', + passTemplateId: 'tpl-hypoxi', + serviceId: 'srv-hypoxi', + type: 'OCCASION_BASED', + totalPasses: 10, + remainingPasses: 9, + startDate: new Date('2026-11-01T09:00:00.000Z'), + expirationDate: new Date('2026-12-31T22:59:59.000Z'), + isActive: true, + isFrozen: false, + frozenAt: null, + cancelledAt: null, + createdAt: new Date('2026-11-01T09:00:00.000Z'), + passTemplate: { + name: '10 Alkalmas Hypoxi Bérlet', + serviceId: 'srv-hypoxi', + service: { name: 'Hypoxi' }, + }, + service: { name: 'Hypoxi' }, +}; +const circuitPass = { + ...hypoxiPass, + id: 'pass-circuit', + passTemplateId: 'tpl-circuit', + serviceId: null, + type: 'TIME_BASED', + totalPasses: null, + remainingPasses: null, + passTemplate: { name: 'Havi Köredzés Bérlet', serviceId: null, service: null }, + service: null, +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const checkIn = (overrides: Record) => ({ + studioId: STUDIO, + guestId: 'guest-1', + guestPassId: null, + guestPass: null, + userId: staff.id, + user: staff, + visitType: 'CIRCUIT_TRAINING', + status: 'SUCCESS', + reason: null, + passUsage: null, + ...overrides, +}); + +describe('GuestsService — belépések a vendégadatlapon (TICKET-40)', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let prismaMock: any; + let service: GuestsService; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const givenGuest = (checkIns: any[]) => { + prismaMock.guest.findFirst.mockResolvedValue({ + id: 'guest-1', + studioId: STUDIO, + barcode: '100002', + firstName: 'Dóra', + lastName: 'Horváth', + email: null, + phone: null, + birthDate: null, + nameDay: null, + status: 'ACTIVE', + notes: null, + isActive: true, + createdAt: new Date('2026-10-01T09:00:00.000Z'), + updatedAt: new Date('2026-10-01T09:00:00.000Z'), + passes: [], + checkIns, + }); + }; + + beforeEach(() => { + prismaMock = { guest: { findFirst: vi.fn() } }; + const financeMock = { ensureDuesForGuest: vi.fn().mockResolvedValue(undefined) }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + service = new GuestsService(prismaMock as any, financeMock as any); + }); + + it('a lekérés a beléptetési naplót kéri, legújabb elöl, korlátozva, dupla beolvasás nélkül', async () => { + givenGuest([]); + await service.findOne('guest-1', STUDIO); + + const { include } = prismaMock.guest.findFirst.mock.calls[0][0]; + expect(include.checkIns).toMatchObject({ + where: { status: { not: 'DUPLICATE_SCAN' } }, + orderBy: { createdAt: 'desc' }, + take: GUEST_ARRIVALS_LIMIT, + }); + expect(include.checkIns.include).toMatchObject({ + user: true, + passUsage: { include: { service: true } }, + }); + }); + + it('kezelés, tagsággal és időalapú bérlettel történt köredzés, sztornó és elutasítás leképezése', async () => { + givenGuest([ + checkIn({ + id: 'chk-treatment', + createdAt: new Date('2026-11-10T08:00:00.000Z'), + visitType: 'TREATMENT', + guestPassId: hypoxiPass.id, + guestPass: hypoxiPass, + reason: 'Kezelés: Hypoxi', + passUsage: { service: { name: 'Hypoxi' } }, + }), + checkIn({ + id: 'chk-membership', + createdAt: new Date('2026-11-11T07:30:00.000Z'), + }), + checkIn({ + id: 'chk-circuit-pass', + createdAt: new Date('2026-11-09T16:00:00.000Z'), + guestPassId: circuitPass.id, + guestPass: circuitPass, + }), + checkIn({ + id: 'chk-voided', + createdAt: new Date('2026-11-08T10:00:00.000Z'), + visitType: 'TREATMENT', + status: 'CANCELLED', + guestPassId: hypoxiPass.id, + guestPass: hypoxiPass, + reason: 'Recepciós sztornó: Téves beléptetés', + passUsage: { service: { name: 'Hypoxi' } }, + }), + checkIn({ + id: 'chk-denied', + createdAt: new Date('2026-11-07T10:00:00.000Z'), + status: 'DENIED', + guestPassId: hypoxiPass.id, + guestPass: hypoxiPass, + reason: 'Nincs érvényes bérlet.', + }), + ]); + + const guest = await service.findOne('guest-1', STUDIO); + + expect(guest.arrivals?.map((a) => a.id)).toEqual([ + 'chk-membership', + 'chk-treatment', + 'chk-circuit-pass', + 'chk-voided', + 'chk-denied', + ]); + const byId = Object.fromEntries((guest.arrivals ?? []).map((a) => [a.id, a])); + + expect(byId['chk-treatment']).toEqual({ + id: 'chk-treatment', + occurredAt: '2026-11-10T08:00:00.000Z', + visitType: VisitType.TREATMENT, + serviceName: 'Hypoxi', + guestPassId: hypoxiPass.id, + passName: '10 Alkalmas Hypoxi Bérlet', + staffName: 'Kiss Réka', + status: CheckInStatus.SUCCESS, + reason: null, + }); + expect(byId['chk-membership']).toMatchObject({ + visitType: VisitType.CIRCUIT_TRAINING, + serviceName: null, + guestPassId: null, + passName: MEMBERSHIP_ARRIVAL_PASS_NAME, + status: CheckInStatus.SUCCESS, + }); + expect(byId['chk-circuit-pass']).toMatchObject({ + guestPassId: circuitPass.id, + passName: 'Havi Köredzés Bérlet', + status: CheckInStatus.SUCCESS, + }); + expect(byId['chk-voided']).toMatchObject({ + status: CheckInStatus.CANCELLED, + reason: 'Recepciós sztornó: Téves beléptetés', + serviceName: 'Hypoxi', + }); + // Elutasításnál a napló a legutóbbi bérletet jegyzi, szolgáltatás nem történt. + expect(byId['chk-denied']).toMatchObject({ + status: CheckInStatus.DENIED, + reason: 'Nincs érvényes bérlet.', + serviceName: null, + passName: '10 Alkalmas Hypoxi Bérlet', + }); + }); + + it('a dupla beolvasás nem belépés, és a rögzítő hiányában a recepció a rögzítő', async () => { + givenGuest([ + checkIn({ + id: 'chk-dup', + createdAt: new Date('2026-11-11T07:30:05.000Z'), + status: 'DUPLICATE_SCAN', + }), + checkIn({ + id: 'chk-ok', + createdAt: new Date('2026-11-11T07:30:00.000Z'), + user: null, + }), + ]); + + const guest = await service.findOne('guest-1', STUDIO); + + expect(guest.arrivals?.map((a) => a.id)).toEqual(['chk-ok']); + expect(guest.arrivals?.[0].staffName).toBe('Recepciós Munkatárs'); + }); +}); diff --git a/apps/api/src/modules/guests/guests.service.ts b/apps/api/src/modules/guests/guests.service.ts index 586e91f..499aad5 100644 --- a/apps/api/src/modules/guests/guests.service.ts +++ b/apps/api/src/modules/guests/guests.service.ts @@ -13,6 +13,7 @@ import { GuestPassDto, GuestPhotoDto, GuestStatus, + GUEST_ARRIVALS_LIMIT, getOverdueBucket, getOverdueDays, MeasurementDto, @@ -23,6 +24,7 @@ import { PassType, PaymentMethod, Role, + toGuestArrivals, } from '@reform/shared'; import { randomInt } from 'crypto'; import { PrismaService } from '../../prisma/prisma.service'; @@ -106,6 +108,19 @@ export class GuestsService { messageLogs: { orderBy: { sentAt: 'desc' as const }, }, + // Belépések (TICKET-40): a dupla beolvasás nem belépés. + checkIns: { + where: { status: { not: 'DUPLICATE_SCAN' as const } }, + include: { + user: true, + guestPass: { + include: { passTemplate: { include: { service: true } }, service: true }, + }, + passUsage: { include: { service: true } }, + }, + orderBy: { createdAt: 'desc' as const }, + take: GUEST_ARRIVALS_LIMIT, + }, }; } @@ -592,6 +607,24 @@ export class GuestsService { updatedAt: g.updatedAt.toISOString(), passes, treatmentHistory, + arrivals: toGuestArrivals( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (g.checkIns ?? []).map((c: any) => ({ + id: c.id, + createdAt: c.createdAt, + visitType: c.visitType, + status: c.status, + reason: c.reason ?? null, + guestPassId: c.guestPassId ?? null, + passName: c.guestPass?.passTemplate?.name ?? null, + usageServiceName: c.passUsage?.service?.name ?? null, + passServiceName: + c.guestPass?.service?.name ?? + c.guestPass?.passTemplate?.service?.name ?? + null, + staffName: c.user?.name ?? null, + })), + ), memberships: (g.memberships ?? []).map( // eslint-disable-next-line @typescript-eslint/no-explicit-any (m: any) => ({ diff --git a/apps/web/src/pages/reception/model/releaseNotes.ts b/apps/web/src/pages/reception/model/releaseNotes.ts index 293255f..dfe65ce 100644 --- a/apps/web/src/pages/reception/model/releaseNotes.ts +++ b/apps/web/src/pages/reception/model/releaseNotes.ts @@ -96,6 +96,20 @@ export const getJiraIssueUrl = (jiraKey: string): string => `${JIRA_BASE_URL}/browse/${encodeURIComponent(jiraKey)}`; export const RELEASE_NOTES: ReleaseNote[] = [ + { + id: 'v0.3-guest-sheet-arrivals', + version: '0.3', + date: '2026-11-11', + kind: 'IMPROVEMENT', + title: 'Belépések a vendégadatlapon', + summary: 'A vendégadatlapon látszanak a belépések.', + details: [ + 'Az új „Belépések” fül minden belépést mutat, legújabb elöl: időpont, köredzés vagy kezelés (szolgáltatással), bérlet, rögzítő munkatárs és állapot (sikeres, sztornózott, elutasított, az okkal együtt).', + 'A tagsággal vagy időalapú bérlettel történt, levonás nélküli köredzés is megjelenik.', + 'A Bérletek fülön bérletenként lenyitható, melyik belépés kötődött az adott bérlethez.', + ], + jiraKey: 'TICKET-40', + }, { id: 'v0.3-fix-pass-freeze-calendar-days', version: '0.3', diff --git a/apps/web/src/shared/api/mock-engine.ts b/apps/web/src/shared/api/mock-engine.ts index b1cf377..e454cae 100644 --- a/apps/web/src/shared/api/mock-engine.ts +++ b/apps/web/src/shared/api/mock-engine.ts @@ -68,6 +68,7 @@ import { ServiceDto, stopsBilling, TodayDashboardDto, + toGuestArrivals, VisitDto, VisitType, } from '@reform/shared'; @@ -138,6 +139,40 @@ function voidArrival( return true; } +/** + * A vendég belépései a beléptetési naplóból, a közös `toGuestArrivals`-szal (az API `mapGuest` + * párja, TICKET-40): a bérlet és a levonás szolgáltatása a vendég bérleteiből oldódik fel. + */ +function syncGuestArrivals(state: MockDatabaseState, guest: GuestDto) { + const passes = new Map((guest.passes ?? []).map((p) => [p.id, p])); + const usageServiceByCheckIn = new Map(); + for (const p of guest.passes ?? []) { + for (const u of p.usageLogs ?? []) { + if (u.checkInId) usageServiceByCheckIn.set(u.checkInId, u.serviceName ?? null); + } + } + guest.arrivals = toGuestArrivals( + state.checkIns + .filter((c) => c.guestId === guest.id) + .map((c) => { + const pass = c.guestPassId ? passes.get(c.guestPassId) : undefined; + return { + id: c.id, + createdAt: c.createdAt, + // Az adatbázis alapértéke, ha a napló nem jegyzi (elutasítás). + visitType: c.visitType ?? VisitType.CIRCUIT_TRAINING, + status: c.status, + reason: c.reason ?? null, + guestPassId: c.guestPassId ?? null, + passName: pass?.passTemplateName ?? null, + usageServiceName: usageServiceByCheckIn.get(c.id) ?? null, + passServiceName: pass?.serviceName ?? null, + staffName: c.staffName ?? null, + }; + }), + ); +} + /** Korrekciós napló (az API `logCorrection` párja). */ function logCorrection( state: MockDatabaseState, @@ -1195,6 +1230,7 @@ class MockStudioEngine { const now = new Date(); for (const g of parsed.guests) { for (const p of g.passes ?? []) syncPassState(p, now); + syncGuestArrivals(parsed, g); } return parsed; } catch { @@ -1223,6 +1259,7 @@ class MockStudioEngine { (a, b) => new Date(b.usedAt).getTime() - new Date(a.usedAt).getTime(), ); g.payments = state.payments.filter((p) => p.guestId === g.id); + syncGuestArrivals(state, g); } localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } diff --git a/apps/web/src/widgets/guest-profile/model/arrivals.test.ts b/apps/web/src/widgets/guest-profile/model/arrivals.test.ts new file mode 100644 index 0000000..f49a004 --- /dev/null +++ b/apps/web/src/widgets/guest-profile/model/arrivals.test.ts @@ -0,0 +1,105 @@ +import { + CheckInStatus, + GuestArrivalDto, + MEMBERSHIP_ARRIVAL_PASS_NAME, + toGuestArrivals, + VisitType, +} from '@reform/shared'; +import { describe, expect, it } from 'vitest'; +import { + arrivalsForPass, + arrivalTitle, + formatArrivalDateTime, +} from './arrivals'; + +const arrival = (overrides: Partial): GuestArrivalDto => ({ + id: 'chk-1', + occurredAt: '2026-11-10T13:52:00.000Z', + visitType: VisitType.CIRCUIT_TRAINING, + serviceName: null, + guestPassId: null, + passName: null, + staffName: 'Recepciós Munkatárs', + status: CheckInStatus.SUCCESS, + reason: null, + ...overrides, +}); + +describe('vendégadatlap belépések (TICKET-40)', () => { + it('a cím a belépés fajtája, a szolgáltatással, ha más a neve', () => { + expect(arrivalTitle(arrival({}))).toBe('Köredzés'); + expect(arrivalTitle(arrival({ serviceName: 'Köredzés' }))).toBe('Köredzés'); + expect( + arrivalTitle( + arrival({ visitType: VisitType.TREATMENT, serviceName: 'Hypoxi' }), + ), + ).toBe('Kezelés · Hypoxi'); + expect(arrivalTitle(arrival({ visitType: VisitType.TREATMENT }))).toBe( + 'Kezelés', + ); + }); + + it('az időpont a stúdió időzónájában látszik', () => { + // 13:52 UTC novemberben 14:52 Budapesten. + expect(formatArrivalDateTime('2026-11-10T13:52:00.000Z')).toContain('14:52'); + }); + + it('a bérlet belépései a bérlethez kötött sorok', () => { + const list = [ + arrival({ id: 'a', guestPassId: 'pass-1' }), + arrival({ id: 'b', guestPassId: null }), + arrival({ id: 'c', guestPassId: 'pass-1', status: CheckInStatus.CANCELLED }), + ]; + expect(arrivalsForPass(list, 'pass-1').map((a) => a.id)).toEqual(['a', 'c']); + expect(arrivalsForPass(undefined, 'pass-1')).toEqual([]); + }); + + it('a közös leképezés (API és mock): sorrend, dupla beolvasás, tagság, okok', () => { + const base = { + visitType: 'CIRCUIT_TRAINING' as const, + reason: null, + guestPassId: null, + passName: null, + usageServiceName: null, + passServiceName: null, + staffName: null, + }; + const result = toGuestArrivals([ + { ...base, id: 'old', createdAt: '2026-11-01T08:00:00.000Z', status: 'SUCCESS' }, + { ...base, id: 'dup', createdAt: '2026-11-02T08:00:00.000Z', status: 'DUPLICATE_SCAN' }, + { + ...base, + id: 'denied', + createdAt: '2026-11-03T08:00:00.000Z', + status: 'DENIED', + reason: 'Nincs érvényes bérlet.', + guestPassId: 'pass-1', + passName: '10 Alkalmas Hypoxi Bérlet', + passServiceName: 'Hypoxi', + }, + { + ...base, + id: 'treatment', + createdAt: '2026-11-04T08:00:00.000Z', + status: 'SUCCESS', + visitType: 'TREATMENT', + reason: 'Kezelés: Hypoxi', + guestPassId: 'pass-1', + passName: '10 Alkalmas Hypoxi Bérlet', + usageServiceName: 'Hypoxi', + }, + ]); + + expect(result.map((a) => a.id)).toEqual(['treatment', 'denied', 'old']); + expect(result[0]).toMatchObject({ serviceName: 'Hypoxi', reason: null }); + expect(result[1]).toMatchObject({ + serviceName: null, + reason: 'Nincs érvényes bérlet.', + passName: '10 Alkalmas Hypoxi Bérlet', + }); + expect(result[2]).toMatchObject({ + passName: MEMBERSHIP_ARRIVAL_PASS_NAME, + staffName: 'Recepciós Munkatárs', + }); + }); +}); diff --git a/apps/web/src/widgets/guest-profile/model/arrivals.ts b/apps/web/src/widgets/guest-profile/model/arrivals.ts new file mode 100644 index 0000000..4c2b3e8 --- /dev/null +++ b/apps/web/src/widgets/guest-profile/model/arrivals.ts @@ -0,0 +1,55 @@ +import { + CheckInStatus, + GuestArrivalDto, + GuestArrivalStatus, + STUDIO_TIME_ZONE, + VisitType, +} from '@reform/shared'; +import type { BadgeProps } from '@/shared/ui'; + +type BadgeVariant = NonNullable; + +/** Ennyi belépés látszik elsőre; a „Továbbiak” ennyivel bővíti a listát. */ +export const ARRIVALS_PAGE_SIZE = 10; + +/** A belépés állapotának jelvénye (UI-specifikáció 5.7). */ +export const ARRIVAL_STATUS_BADGE: Record< + GuestArrivalStatus, + { label: string; variant: BadgeVariant } +> = { + [CheckInStatus.SUCCESS]: { label: 'Sikeres', variant: 'success' }, + [CheckInStatus.CANCELLED]: { label: 'Sztornózva', variant: 'secondary' }, + [CheckInStatus.DENIED]: { label: 'Elutasítva', variant: 'destructive' }, +}; + +/** „Köredzés” vagy „Kezelés · Hypoxi”: a belépés fajtája, a szolgáltatással, ha más a neve. */ +export function arrivalTitle(arrival: GuestArrivalDto): string { + const kind = + arrival.visitType === VisitType.TREATMENT ? 'Kezelés' : 'Köredzés'; + const service = arrival.serviceName?.trim(); + return service && service.toLowerCase() !== kind.toLowerCase() + ? `${kind} · ${service}` + : kind; +} + +/** Az adott bérlethez kötött belépések (a lista sorrendjében). */ +export function arrivalsForPass( + arrivals: readonly GuestArrivalDto[] | undefined, + passId: string, +): GuestArrivalDto[] { + return (arrivals ?? []).filter((a) => a.guestPassId === passId); +} + +const arrivalDateTimeFormat = new Intl.DateTimeFormat('hu-HU', { + timeZone: STUDIO_TIME_ZONE, + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', +}); + +/** Időpont a stúdió időzónájában (Europe/Budapest), pl. „2026. nov. 10. 14:52”. */ +export function formatArrivalDateTime(iso: string): string { + return arrivalDateTimeFormat.format(new Date(iso)); +} diff --git a/apps/web/src/widgets/guest-profile/ui/GuestArrivals.tsx b/apps/web/src/widgets/guest-profile/ui/GuestArrivals.tsx new file mode 100644 index 0000000..69194d5 --- /dev/null +++ b/apps/web/src/widgets/guest-profile/ui/GuestArrivals.tsx @@ -0,0 +1,154 @@ +import { + CheckInStatus, + GUEST_ARRIVALS_LIMIT, + GuestArrivalDto, +} from '@reform/shared'; +import { ChevronDown, ChevronUp } from 'lucide-react'; +import React, { useId, useState } from 'react'; +import { cn } from '@/shared/lib/utils'; +import { Badge, Button } from '@/shared/ui'; +import { + ARRIVAL_STATUS_BADGE, + ARRIVALS_PAGE_SIZE, + arrivalTitle, + formatArrivalDateTime, +} from '../model/arrivals'; + +/** Egy belépés sora: fajta és szolgáltatás, bérlet, időpont, rögzítő, állapot (és ok). */ +const ArrivalRow: React.FC<{ arrival: GuestArrivalDto; showPass: boolean }> = ({ + arrival, + showPass, +}) => { + const badge = ARRIVAL_STATUS_BADGE[arrival.status]; + const isVoided = arrival.status === CheckInStatus.CANCELLED; + return ( +
  • +
    +

    + {arrivalTitle(arrival)} + {showPass && arrival.passName && ( + · {arrival.passName} + )} +

    +

    + {' '} + · Rögzítette: {arrival.staffName} +

    + {arrival.reason && ( +

    + {/* A sztornó oka önmagát magyarázza („Recepciós sztornó: …”, „Levonás visszavonva: …”). */} + {arrival.status === CheckInStatus.DENIED + ? `Elutasítás oka: ${arrival.reason}` + : arrival.reason} +

    + )} +
    + {badge.label} +
  • + ); +}; + +/** Belépések listája, lapozás helyett „Továbbiak” bővítéssel. */ +const ArrivalList: React.FC<{ + arrivals: readonly GuestArrivalDto[]; + label: string; + showPass: boolean; +}> = ({ arrivals, label, showPass }) => { + const [visibleCount, setVisibleCount] = useState(ARRIVALS_PAGE_SIZE); + const hidden = arrivals.length - visibleCount; + return ( +
    +
      + {arrivals.slice(0, visibleCount).map((a) => ( + + ))} +
    + {hidden > 0 && ( + + )} +
    + ); +}; + +/** A vendég összes belépése (TICKET-40): sikeres, sztornózott és elutasított, legújabb elöl. */ +export const GuestArrivalsSection: React.FC<{ + arrivals: readonly GuestArrivalDto[]; +}> = ({ arrivals }) => { + const titleId = useId(); + return ( +
    +

    + Belépések +

    + {arrivals.length === 0 ? ( +

    + Még nincs rögzített belépés ennél a vendégnél. +

    + ) : ( + <> + + {arrivals.length >= GUEST_ARRIVALS_LIMIT && ( +

    + A legutóbbi {GUEST_ARRIVALS_LIMIT} belépés látszik. +

    + )} + + )} +
    + ); +}; + +/** Egy bérlet belépései a Bérletek fülön, lenyitható listában. */ +export const PassArrivals: React.FC<{ + arrivals: readonly GuestArrivalDto[]; + passName: string; +}> = ({ arrivals, passName }) => { + const [isOpen, setIsOpen] = useState(false); + const listId = useId(); + if (arrivals.length === 0) { + return ( +

    + Ezzel a bérlettel még nem volt belépés. +

    + ); + } + return ( +
    + + +
    + ); +}; diff --git a/apps/web/src/widgets/guest-profile/ui/GuestModal.tsx b/apps/web/src/widgets/guest-profile/ui/GuestModal.tsx index a1852b7..f3d117a 100644 --- a/apps/web/src/widgets/guest-profile/ui/GuestModal.tsx +++ b/apps/web/src/widgets/guest-profile/ui/GuestModal.tsx @@ -61,6 +61,8 @@ import { ToggleGroup, ToggleGroupItem, } from '@/shared/ui'; +import { arrivalsForPass } from '../model/arrivals'; +import { GuestArrivalsSection, PassArrivals } from './GuestArrivals'; const DUE_STATUS_LABEL: Record = { [MembershipDueStatus.PENDING]: 'függőben', @@ -95,6 +97,7 @@ type GuestSheetTab = | 'overview' | 'memberships' | 'passes' + | 'arrivals' | 'treatments' | 'payments' | 'measurements' @@ -556,6 +559,11 @@ export const GuestModal: React.FC = ({ count: guest?.memberships?.length ?? 0, }, { id: 'passes', label: 'Bérletek', count: guest?.passes?.length ?? 0 }, + { + id: 'arrivals', + label: 'Belépések', + count: guest?.arrivals?.length ?? 0, + }, { id: 'treatments', label: 'Kezelések', @@ -1104,6 +1112,13 @@ export const GuestModal: React.FC = ({ )} + +
    + +
    ); })} @@ -1144,6 +1159,11 @@ export const GuestModal: React.FC = ({ )} + {/* BELÉPÉSEK (TICKET-40) */} + {guest && activeTab === 'arrivals' && ( + + )} + {/* KEZELÉSI ELŐZMÉNYEK ÉS VISSZAVONÁS */} {guest && activeTab === 'treatments' && (
    diff --git a/packages/shared/src/contracts/index.ts b/packages/shared/src/contracts/index.ts index ce6c813..039d281 100644 --- a/packages/shared/src/contracts/index.ts +++ b/packages/shared/src/contracts/index.ts @@ -277,6 +277,33 @@ export interface AuditLogDto { details: string; } +/** A vendégadatlapon megjelenő belépés állapota: sikeres, sztornózott vagy elutasított. */ +export type GuestArrivalStatus = + | CheckInStatus.SUCCESS + | CheckInStatus.CANCELLED + | CheckInStatus.DENIED; + +/** + * Egy belépés a vendégadatlapon (TICKET-40): a beléptetési napló (`logged_check_ins`) sora. + * A sztornózott belépés látogatása törlődik, a napló sora `CANCELLED` állapotban, a sztornó + * okával megmarad; az elutasított beolvasás `DENIED` sor az elutasítás okával. + */ +export interface GuestArrivalDto { + /** A beléptetés (napló) azonosítója. */ + id: string; + occurredAt: string; + visitType: VisitType; + /** Kezelésnél a levont szolgáltatás, köredzésnél a bérlet szolgáltatása (ha van). */ + serviceName: string | null; + guestPassId: string | null; + /** A bérlet neve; havi tagsággal történt köredzésnél „Havi köredzés tagság”. */ + passName: string | null; + staffName: string; + status: GuestArrivalStatus; + /** Sztornónál és elutasításnál az ok; sikeres belépésnél nincs. */ + reason: string | null; +} + export interface GuestDto { id: string; studioId: string; @@ -296,6 +323,8 @@ export interface GuestDto { memberships?: MembershipDto[]; treatmentHistory?: PassUsageLogDto[]; visits?: VisitDto[]; + /** Belépések (beléptetési napló), legújabb elöl, legfeljebb `GUEST_ARRIVALS_LIMIT` (TICKET-40). */ + arrivals?: GuestArrivalDto[]; payments?: PaymentDto[]; measurements?: MeasurementDto[]; photos?: GuestPhotoDto[]; diff --git a/packages/shared/src/guest-arrivals.ts b/packages/shared/src/guest-arrivals.ts new file mode 100644 index 0000000..9bc38a9 --- /dev/null +++ b/packages/shared/src/guest-arrivals.ts @@ -0,0 +1,76 @@ +import type { GuestArrivalDto, GuestArrivalStatus } from './contracts'; +import { CheckInStatus, VisitType } from './enums'; + +/** + * A vendégadatlap belépés-listája (TICKET-40). Tiszta függvény: az API és a kliensoldali mock + * motor ugyanezzel képezi le a beléptetési naplót, így a kettő azonos választ ad. + */ + +/** Ennyi legutóbbi belépés jön a vendég adataival. */ +export const GUEST_ARRIVALS_LIMIT = 100; + +/** Bérlet nélküli sikeres vagy sztornózott köredzés: havi tagsággal történt. */ +export const MEMBERSHIP_ARRIVAL_PASS_NAME = 'Havi köredzés tagság'; + +const LISTED_STATUSES: ReadonlySet = new Set([ + CheckInStatus.SUCCESS, + CheckInStatus.CANCELLED, + CheckInStatus.DENIED, +]); + +/** Egy beléptetési napló sora, a kapcsolt bérlettel és levonással feloldva. */ +export interface GuestArrivalSource { + id: string; + createdAt: Date | string; + visitType: VisitType | `${VisitType}`; + status: CheckInStatus | `${CheckInStatus}`; + reason: string | null; + guestPassId: string | null; + /** A bérlet sablonjának neve, ha a belépés bérlethez kötődik. */ + passName: string | null; + /** A belépéshez tartozó levonás szolgáltatása (kezelés). */ + usageServiceName: string | null; + /** A bérlet szolgáltatása (szolgáltatáshoz kötött bérletnél). */ + passServiceName: string | null; + staffName: string | null; +} + +/** + * Napló → belépés-lista: csak a sikeres, sztornózott és elutasított sorok (a dupla beolvasás nem + * belépés), legújabb elöl, legfeljebb `limit` darab. + */ +export function toGuestArrivals( + sources: readonly GuestArrivalSource[], + limit = GUEST_ARRIVALS_LIMIT, +): GuestArrivalDto[] { + return sources + .filter((s) => LISTED_STATUSES.has(s.status)) + .map((s): GuestArrivalDto => { + const status = s.status as GuestArrivalStatus; + const visitType = s.visitType as VisitType; + const viaMembership = + status !== CheckInStatus.DENIED && + visitType === VisitType.CIRCUIT_TRAINING && + !s.guestPassId; + return { + id: s.id, + occurredAt: new Date(s.createdAt).toISOString(), + visitType, + // Elutasításnál a napló a legutóbbi bérletet jegyzi, de szolgáltatás nem történt. + serviceName: + status === CheckInStatus.DENIED + ? null + : ((visitType === VisitType.TREATMENT ? s.usageServiceName : null) ?? + s.passServiceName ?? + null), + guestPassId: s.guestPassId, + passName: + s.passName ?? (viaMembership ? MEMBERSHIP_ARRIVAL_PASS_NAME : null), + staffName: s.staffName ?? 'Recepciós Munkatárs', + status, + reason: status === CheckInStatus.SUCCESS ? null : s.reason, + }; + }) + .sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)) + .slice(0, limit); +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0bb4478..68baab2 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -6,3 +6,4 @@ export * from './errors'; export * from './membership-dues'; export * from './birthday'; export * from './pass-rules'; +export * from './guest-arrivals'; From 15b874a8f5cdae96c5be6310ce3d1a2027053d78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Sikl=C3=B3di?= Date: Wed, 11 Nov 2026 21:19:06 +0100 Subject: [PATCH 2/3] =?UTF-8?q?feat(api,web,shared):=20TICKET-42=20vend?= =?UTF-8?q?=C3=A9g=20neme,=20a=20n=C5=91i=20k=C3=B6redz=C3=A9s=20csak=20n?= =?UTF-8?q?=C5=91i=20vend=C3=A9gnek?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Guest.gender (nő / férfi / nincs megadva): Prisma enum, vendégűrlap (új vendégnél kötelező, a keresztnévből előtöltve a közös inferGenderFromFirstName-mel), adatlap figyelmeztetés hiányzó nemnél, API DTO-k, contracts, mock, seed. - Service.womenOnly: szolgáltatás-kezelő „Csak nőknek” jelölő és jelvény, seed (köredzés). - Membership.serviceId: a havi tagság a stúdió köredzés szolgáltatásához kötődik (pickCircuitMembershipService), új tagság eladásakor beállítva. - Közös szabály: womenOnlyAccess (packages/shared/src/gender.ts); a resolveArrivalEntitlement férfi vendégnél nem veszi figyelembe a csak nőknek szóló tagságot / bérletet (WOMEN_ONLY ok). Hibakódok: PASS.WOMEN_ONLY (bérlet / tagság eladás), CHECKIN.WOMEN_ONLY (kezeléslevonás). „Nincs megadva” nemnél engedélyezett, GENDER_MISSING figyelmeztetéssel. API és mock azonos. - Felület: sell-pass, tagság eladás és kezelésválasztó férfi vendégnél nem engedi a csak nőknek szóló sablont / kezelést. - Migráció 20261111180000_guest_gender_women_only: oszlopok + a meglévő vendégek neme a keresztnévből (ugyanaz a névlista), a CIRCUIT köredzés womenOnly, a tagságok a köredzésre. - Tesztek: eladás, tagság, beolvasás, kezeléslevonás férfi / női / ismeretlen nemű vendéggel (API spec, shared unit, mock paritás). Újdonságok bejegyzés, ARCHITECTURE ADR-03. Co-Authored-By: Claude Opus 5.5 --- .../migration.sql | 86 ++++++++ apps/api/prisma/schema.prisma | 18 +- apps/api/prisma/seed.ts | 30 ++- .../check-ins/check-ins.service.spec.ts | 118 +++++++++++ .../modules/check-ins/check-ins.service.ts | 23 +- .../src/modules/finance/finance.controller.ts | 8 + .../modules/finance/finance.service.spec.ts | 65 ++++++ .../src/modules/finance/finance.service.ts | 38 +++- apps/api/src/modules/guests/gender.spec.ts | 180 ++++++++++++++++ apps/api/src/modules/guests/guests.dto.ts | 15 +- apps/api/src/modules/guests/guests.service.ts | 7 + .../modules/passes/passes-women-only.spec.ts | 198 ++++++++++++++++++ apps/api/src/modules/passes/passes.dto.ts | 8 + apps/api/src/modules/passes/passes.service.ts | 28 ++- .../model/treatmentPreselection.test.ts | 1 + .../ui/DeductTreatmentModal.tsx | 34 ++- .../features/sell-pass/ui/SellPassModal.tsx | 40 +++- .../src/pages/reception/model/releaseNotes.ts | 15 ++ .../src/pages/reception/ui/ReceptionPage.tsx | 2 + .../pages/reception/ui/passes/PassesTab.tsx | 40 +++- apps/web/src/shared/api/client.ts | 5 + apps/web/src/shared/api/mock-engine.ts | 124 ++++++++++- .../shared/api/mock-engine.women-only.test.ts | 124 +++++++++++ apps/web/src/shared/lib/notify/notify.ts | 6 + .../widgets/guest-profile/ui/GuestModal.tsx | 141 ++++++++++++- docs/ARCHITECTURE.md | 1 + packages/shared/src/contracts/index.ts | 9 + packages/shared/src/enums.ts | 11 +- packages/shared/src/errors/catalog.ts | 2 + packages/shared/src/gender.ts | 118 +++++++++++ packages/shared/src/index.ts | 1 + packages/shared/src/pass-rules.ts | 53 ++++- 32 files changed, 1501 insertions(+), 48 deletions(-) create mode 100644 apps/api/prisma/migrations/20261111180000_guest_gender_women_only/migration.sql create mode 100644 apps/api/src/modules/guests/gender.spec.ts create mode 100644 apps/api/src/modules/passes/passes-women-only.spec.ts create mode 100644 apps/web/src/shared/api/mock-engine.women-only.test.ts create mode 100644 packages/shared/src/gender.ts diff --git a/apps/api/prisma/migrations/20261111180000_guest_gender_women_only/migration.sql b/apps/api/prisma/migrations/20261111180000_guest_gender_women_only/migration.sql new file mode 100644 index 0000000..ea7a197 --- /dev/null +++ b/apps/api/prisma/migrations/20261111180000_guest_gender_women_only/migration.sql @@ -0,0 +1,86 @@ +-- TICKET-42: a vendég neme, a csak nőknek szóló szolgáltatás (női köredzés) és a havi tagság +-- szolgáltatása. Férfi vendégnek csak nőknek szóló bérlet / tagság nem adható el, és vele nem +-- léptethető be (a szabály a közös `womenOnlyAccess`, packages/shared/src/gender.ts). + +-- CreateEnum +CREATE TYPE "Gender" AS ENUM ('FEMALE', 'MALE', 'UNSPECIFIED'); + +-- AlterTable +ALTER TABLE "guests" ADD COLUMN "gender" "Gender" NOT NULL DEFAULT 'UNSPECIFIED'; + +-- AlterTable +ALTER TABLE "services" ADD COLUMN "women_only" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "memberships" ADD COLUMN "service_id" TEXT; + +-- AddForeignKey +ALTER TABLE "memberships" ADD CONSTRAINT "memberships_service_id_fkey" FOREIGN KEY ("service_id") REFERENCES "services"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- Adatátvezetés 1: a meglévő vendégek neme a keresztnév alapján, ugyanazzal a névlistával és +-- normalizálással, mint az `inferGenderFromFirstName` (első tag, kisbetű, ékezet nélkül). Amit +-- nem ismer fel (pl. „Élő”), „nincs megadva” (UNSPECIFIED) marad. A listák egyezését az API +-- `gender.spec.ts` tesztje ellenőrzi. +-- FEMALE_FIRST_NAMES +UPDATE "guests" SET "gender" = 'FEMALE' +WHERE translate( + lower(regexp_replace(btrim("first_name"), '[[:space:]-].*$', '')), + 'ÁÉÍÓÖŐÚÜŰáéíóöőúüű', + 'aeiooouuuaeiooouuu' +) IN ( + 'adel', 'adrienn', 'agnes', 'agota', 'alexandra', 'aliz', 'amalia', 'andrea', 'angela', + 'aniko', 'anita', 'anna', 'aranka', 'barbara', 'beata', 'beatrix', 'bernadett', 'bettina', + 'bianka', 'boglarka', 'borbala', 'boroka', 'brigitta', 'cecilia', 'cintia', 'csenge', + 'csilla', 'dalma', 'diana', 'dominika', 'dora', 'dorina', 'dorka', 'dorottya', 'edina', + 'edit', 'eleonora', 'ella', 'emese', 'emilia', 'emma', 'eniko', 'erika', 'erzsebet', + 'eszter', 'etelka', 'eva', 'evelin', 'fanni', 'flora', 'franciska', 'fruzsina', 'gabriella', + 'georgina', 'gizella', 'greta', 'gyongyi', 'hajnalka', 'hanna', 'hedvig', 'helga', + 'henrietta', 'ibolya', 'ildiko', 'ilona', 'iren', 'irma', 'ivett', 'izabella', 'janka', + 'jazmin', 'johanna', 'jolan', 'judit', 'julia', 'julianna', 'kamilla', 'karolina', 'kata', + 'katalin', 'kinga', 'kincso', 'kitti', 'klara', 'klaudia', 'kornelia', 'kriszta', + 'krisztina', 'lara', 'laura', 'lea', 'lena', 'lenke', 'lili', 'liliana', 'lilla', 'linda', + 'livia', 'luca', 'lujza', 'magdolna', 'maja', 'margit', 'maria', 'mariann', 'marianna', + 'marietta', 'marta', 'melinda', 'mira', 'monika', 'natalia', 'nikoletta', 'nikolett', + 'noemi', 'nora', 'olga', 'orsolya', 'panna', 'patricia', 'petra', 'piroska', 'ramona', + 'rebeka', 'regina', 'reka', 'renata', 'rita', 'roza', 'rozalia', 'sara', 'sarolta', + 'szabina', 'szilvia', 'szofia', 'szonja', 'tamara', 'terez', 'teodora', 'timea', 'tunde', + 'valentina', 'valeria', 'vanda', 'vera', 'veronika', 'viktoria', 'vilma', 'viola', 'virag', + 'vivien', 'zita', 'zoe', 'zsanett', 'zsofi', 'zsofia', 'zsuzsa', 'zsuzsanna' +); + +-- MALE_FIRST_NAMES +UPDATE "guests" SET "gender" = 'MALE' +WHERE translate( + lower(regexp_replace(btrim("first_name"), '[[:space:]-].*$', '')), + 'ÁÉÍÓÖŐÚÜŰáéíóöőúüű', + 'aeiooouuuaeiooouuu' +) IN ( + 'abel', 'adam', 'adrian', 'akos', 'albert', 'alex', 'andor', 'andras', 'antal', 'arnold', + 'aron', 'arpad', 'attila', 'balazs', 'balint', 'barna', 'barnabas', 'bela', 'bence', + 'benedek', 'benjamin', 'bertalan', 'botond', 'csaba', 'csongor', 'daniel', 'david', 'denes', + 'dezso', 'dominik', 'domonkos', 'ede', 'elek', 'elemer', 'emil', 'endre', 'erik', 'erno', + 'ervin', 'ferenc', 'gabor', 'gaspar', 'gellert', 'gergely', 'gergo', 'geza', 'gusztav', + 'gyorgy', 'gyula', 'henrik', 'hunor', 'ignac', 'imre', 'istvan', 'ivan', 'jakab', 'janos', + 'jeno', 'jozsef', 'kalman', 'karoly', 'kende', 'kevin', 'kolos', 'kornel', 'kristof', + 'krisztian', 'lajos', 'laszlo', 'levente', 'lorand', 'lorant', 'lorinc', 'marcell', 'mario', + 'mark', 'martin', 'marton', 'mate', 'matyas', 'menyhert', 'mihaly', 'miklos', 'milan', + 'nandor', 'nimrod', 'noel', 'norbert', 'oliver', 'otto', 'pal', 'patrik', 'peter', 'rezso', + 'richard', 'robert', 'roland', 'rudolf', 'samuel', 'sandor', 'sebestyen', 'simon', + 'szabolcs', 'szilard', 'szilveszter', 'tamas', 'tibor', 'tivadar', 'vencel', 'viktor', + 'vilmos', 'vince', 'zalan', 'zoltan', 'zsigmond', 'zsolt', 'zsombor' +); + +-- Adatátvezetés 2: a köredzés (CIRCUIT kategóriájú „Köredzés” / „Női köredzés”) szolgáltatás csak nőknek szól. +UPDATE "services" SET "women_only" = true +WHERE "category" = 'CIRCUIT' + AND translate(lower("name"), 'ÁÉÍÓÖŐÚÜŰáéíóöőúüű', 'aeiooouuuaeiooouuu') LIKE '%koredzes%'; + +-- Adatátvezetés 3: a meglévő havi tagságok a stúdió köredzés szolgáltatásához kötődnek +-- (`pickCircuitMembershipService`: CIRCUIT kategória, az aktív elöl, azon belül a legrégebbi). +UPDATE "memberships" AS m SET "service_id" = ( + SELECT s."id" FROM "services" AS s + WHERE s."studio_id" = m."studio_id" AND s."category" = 'CIRCUIT' + ORDER BY s."is_active" DESC, s."created_at" ASC + LIMIT 1 +) +WHERE m."service_id" IS NULL; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 56ad8cf..d5615c2 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -19,6 +19,13 @@ enum GuestStatus { PAUSED } +// TICKET-42: a csak nőknek szóló szolgáltatásokhoz (női köredzés). +enum Gender { + FEMALE + MALE + UNSPECIFIED +} + enum ServiceCategory { CIRCUIT COSMETIC @@ -113,6 +120,7 @@ model Guest { phone String? birthDate DateTime? @map("birth_date") nameDay String? @map("name_day") + gender Gender @default(UNSPECIFIED) status GuestStatus @default(ACTIVE) notes String? isActive Boolean @default(true) @map("is_active") @@ -143,6 +151,8 @@ model Service { category ServiceCategory @default(BODY_SHAPING) durationMinutes Int @default(30) @map("duration_minutes") priceHuf Int @default(0) @map("price_huf") + /// Csak nőknek szól: férfi vendégnek nem adható el, vele nem léptethető be (TICKET-42). + womenOnly Boolean @default(false) @map("women_only") isActive Boolean @default(true) @map("is_active") createdAt DateTime @default(now()) @map("created_at") @@ -150,6 +160,7 @@ model Service { guestPasses GuestPass[] passUsages PassUsageLog[] photos GuestPhoto[] + memberships Membership[] @@index([studioId, isActive]) @@map("services") @@ -159,6 +170,8 @@ model Membership { id String @id @default(uuid()) studioId String @default("default-studio") @map("studio_id") guestId String @map("guest_id") + /// A tagság szolgáltatása (a stúdió köredzése); a csak nőknek szóló korlátozás innen jön. + serviceId String? @map("service_id") monthlyFeeHuf Int @map("monthly_fee_huf") startDate DateTime @map("start_date") dueDay Int @default(10) @map("due_day") @@ -168,8 +181,9 @@ model Membership { resumedAt DateTime? @map("resumed_at") createdAt DateTime @default(now()) @map("created_at") - guest Guest @relation(fields: [guestId], references: [id], onDelete: Cascade) - dues MembershipDue[] + guest Guest @relation(fields: [guestId], references: [id], onDelete: Cascade) + service Service? @relation(fields: [serviceId], references: [id], onDelete: SetNull) + dues MembershipDue[] @@index([guestId, status]) @@map("memberships") diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 5478fc9..ba844d7 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -1,6 +1,7 @@ import { DocumentStatus, DocumentType, + Gender, GuestStatus, MembershipDueStatus, MembershipStatus, @@ -83,7 +84,8 @@ const STAFF: { email: string; name: string; role: Role }[] = [ const LEGACY_STAFF_EMAILS = ['admin@reformstudio.hu', 'recepcio@reformstudio.hu']; const SERVICES = [ - { name: 'Női Köredzés (Havi Tagság)', category: ServiceCategory.CIRCUIT, durationMinutes: 45, priceHuf: 22000 }, + // TICKET-42: a köredzés csak nőknek szól (férfi vendégnek nem adható el, vele nem léptethető be). + { name: 'Női Köredzés (Havi Tagság)', category: ServiceCategory.CIRCUIT, durationMinutes: 45, priceHuf: 22000, womenOnly: true }, { name: 'Hypoxi', category: ServiceCategory.BODY_SHAPING, durationMinutes: 30, priceHuf: 9500 }, { name: 'Reformer Pilates', category: ServiceCategory.BODY_SHAPING, durationMinutes: 50, priceHuf: 6500 }, { name: 'Ultrahangos Kavitációs Zsírbontás', category: ServiceCategory.BODY_SHAPING, durationMinutes: 40, priceHuf: 12000 }, @@ -155,6 +157,7 @@ interface GuestSpec { phone: string; birthDate: Date; nameDay: string; + gender: Gender; status: GuestStatus; notes: string; createdDaysAgo: number; @@ -174,6 +177,7 @@ const DEMO_GUESTS: GuestSpec[] = [ phone: '+36 30 412 8891', birthDate: dateOnly(1991, today.month, today.day), // mai születésnapos (KPI) nameDay: '07-26', + gender: Gender.FEMALE, status: GuestStatus.ACTIVE, notes: 'Köredzés havi tag + Hypoxi alakformáló kúra (kedd/csütörtök)', createdDaysAgo: 90, @@ -341,6 +345,7 @@ const DEMO_GUESTS: GuestSpec[] = [ phone: '+36 70 331 5520', birthDate: dateOnly(1988, 4, 12), nameDay: todayMmDd, // mai névnapos (KPI) + gender: Gender.MALE, status: GuestStatus.ACTIVE, notes: '1-5 napos tagdíj késés + utolsó 1 alkalmas bérlet teszteléséhez', createdDaysAgo: 45, @@ -369,6 +374,7 @@ const DEMO_GUESTS: GuestSpec[] = [ phone: '+36 20 981 1140', birthDate: dateOnly(1995, 11, 8), nameDay: '05-24', + gender: Gender.FEMALE, status: GuestStatus.ACTIVE, notes: 'Érvényes Reformer bérlet (2 alkalom maradt), tartozás nélkül', createdDaysAgo: 60, @@ -390,6 +396,7 @@ const DEMO_GUESTS: GuestSpec[] = [ phone: '+36 30 774 2019', birthDate: dateOnly(1984, 2, 19), nameDay: '03-24', + gender: Gender.MALE, status: GuestStatus.ACTIVE, notes: '15 nap feletti tartozás + lejárt bérlet teszteléséhez', createdDaysAgo: 70, @@ -417,6 +424,7 @@ const DEMO_GUESTS: GuestSpec[] = [ phone: '+36 30 611 9082', birthDate: dateOnly(1993, 9, 14), nameDay: '05-15', + gender: Gender.FEMALE, status: GuestStatus.ACTIVE, notes: '6-15 napos részbefizetett tagdíj + kimerült (0 alkalmas) bérlet', createdDaysAgo: 60, @@ -445,6 +453,7 @@ const DEMO_GUESTS: GuestSpec[] = [ phone: '+36 20 449 3210', birthDate: dateOnly(1990, 6, 5), nameDay: '02-06', + gender: Gender.FEMALE, status: GuestStatus.PAUSED, notes: 'Síbaleset miatt szüneteltetett tagság és fagyasztott bérlet', createdDaysAgo: 20, @@ -625,7 +634,13 @@ async function createPass( } } -async function createMembership(tx: Tx, guestId: string, staffId: string, spec: MembershipSpec) { +async function createMembership( + tx: Tx, + guestId: string, + staffId: string, + spec: MembershipSpec, + serviceId: string | null, +) { const first = billing(spec.billingDaysAgo); const pausedAt = spec.pausedDaysAgo !== undefined ? daysAgo(spec.pausedDaysAgo) : null; // Ugyanaz az ütemezés, mint a `createMembership` / havi pótlás az API-n. @@ -643,6 +658,7 @@ async function createMembership(tx: Tx, guestId: string, staffId: string, spec: data: { studioId, guestId, + serviceId, monthlyFeeHuf: spec.monthlyFeeHuf, startDate: first.dueDate, dueDay: first.dueDay, @@ -709,6 +725,7 @@ async function createDemoGuest( phone: spec.phone, birthDate: spec.birthDate, nameDay: spec.nameDay, + gender: spec.gender, status: spec.status, notes: spec.notes, createdAt: daysAgo(spec.createdDaysAgo), @@ -716,7 +733,14 @@ async function createDemoGuest( }); if (spec.membership) { - await createMembership(tx, guest.id, staffId, spec.membership); + // A havi tagság a köredzés szolgáltatáshoz kötődik (TICKET-42). + await createMembership( + tx, + guest.id, + staffId, + spec.membership, + services.get('Női Köredzés (Havi Tagság)') ?? null, + ); } for (const pass of spec.passes) { await createPass(tx, guest.id, staffId, pass, templates, services); diff --git a/apps/api/src/modules/check-ins/check-ins.service.spec.ts b/apps/api/src/modules/check-ins/check-ins.service.spec.ts index 57c4c6a..e2da11c 100644 --- a/apps/api/src/modules/check-ins/check-ins.service.spec.ts +++ b/apps/api/src/modules/check-ins/check-ins.service.spec.ts @@ -1,4 +1,5 @@ import { + ARRIVAL_DENIED_MESSAGES, CheckInStatus, DUPLICATE_SCAN_COOLDOWN_SECONDS, PassType, @@ -810,4 +811,121 @@ describe('CheckInsService — Kritikus Beléptetési és Köredzés vs. Kezelés expect(monitorGatewayMock.emitCheckInResult).not.toHaveBeenCalled(); }); }); + + describe('TICKET-42 — csak nőknek szóló köredzés a beolvasásnál', () => { + const circuitService = { + id: 'srv-circuit', + name: 'Női Köredzés (Havi Tagság)', + category: 'CIRCUIT', + womenOnly: true, + }; + const membership = (over: Record = {}) => ({ + id: 'mem-1', + status: 'ACTIVE', + monthlyFeeHuf: 22000, + serviceId: 'srv-circuit', + service: circuitService, + dues: [], + ...over, + }); + const circuitPass = (over: Record = {}) => ({ + id: 'pass-circuit', + passTemplateId: 'tpl-unlimited', + serviceId: 'srv-circuit', + service: circuitService, + type: 'TIME_BASED', + totalPasses: null, + remainingPasses: null, + startDate: pastDate(5), + expirationDate: futureDate(25), + isActive: true, + isFrozen: false, + frozenAt: null, + cancelledAt: null, + passTemplate: { name: 'Havi Korlátlan Köredzés Bérlet' }, + ...over, + }); + const reformerPass = { + ...circuitPass(), + id: 'pass-reformer', + passTemplateId: 'tpl-10', + serviceId: 'srv-reformer', + service: { + id: 'srv-reformer', + name: 'Reformer Pilates', + category: 'BODY_SHAPING', + womenOnly: false, + }, + type: 'OCCASION_BASED', + totalPasses: 10, + remainingPasses: 4, + passTemplate: { name: '10 Alkalmas Reformer Bérlet' }, + }; + const guestWith = ( + gender: string, + memberships: unknown[], + passes: unknown[] = [], + ) => ({ + id: 'guest-x', + firstName: gender === 'MALE' ? 'Péter' : 'Anna', + lastName: 'Teszt', + barcode: '100099', + gender, + isActive: true, + memberships, + passes, + }); + + it('női vendég köredzés tagsággal: belép, figyelmeztetés nélkül', async () => { + txMock.guest.findUnique.mockResolvedValue(guestWith('FEMALE', [membership()])); + + const res = await service.processBarcodeCheckIn({ barcode: '100099' }, 'staff-1'); + + expect(res.status).toBe(CheckInStatus.SUCCESS); + expect(res.mode).toBe('CIRCUIT_GREETING'); + expect(res.warnings).not.toContain('GENDER_MISSING'); + }); + + it('férfi vendég csak nőknek szóló tagsággal: elutasítás WOMEN_ONLY okkal, nincs belépés', async () => { + txMock.guest.findUnique.mockResolvedValue(guestWith('MALE', [membership()])); + + const res = await service.processBarcodeCheckIn({ barcode: '100099' }, 'staff-1'); + + expect(res.status).toBe(CheckInStatus.DENIED); + expect(res.reason).toBe(ARRIVAL_DENIED_MESSAGES.WOMEN_ONLY); + expect(txMock.visit.create).not.toHaveBeenCalled(); + expect(txMock.loggedCheckIn.create).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ status: 'DENIED' }) }), + ); + }); + + it('férfi vendég köredzés bérlettel: elutasítás WOMEN_ONLY okkal', async () => { + txMock.guest.findUnique.mockResolvedValue(guestWith('MALE', [], [circuitPass()])); + + const res = await service.processBarcodeCheckIn({ barcode: '100099' }, 'staff-1'); + + expect(res.status).toBe(CheckInStatus.DENIED); + expect(res.reason).toBe(ARRIVAL_DENIED_MESSAGES.WOMEN_ONLY); + }); + + it('férfi vendég köredzés tagsággal és Reformer bérlettel: a kezelésválasztó csak a Reformer bérletet kínálja', async () => { + txMock.guest.findUnique.mockResolvedValue( + guestWith('MALE', [membership()], [circuitPass(), reformerPass]), + ); + + const res = await service.processBarcodeCheckIn({ barcode: '100099' }, 'staff-1'); + + expect(res.status).toBe(CheckInStatus.TREATMENT_SELECTION_REQUIRED); + expect(res.treatmentOptions?.map((o) => o.passId)).toEqual(['pass-reformer']); + }); + + it('nincs megadva nemnél belép, de a recepció GENDER_MISSING figyelmeztetést kap', async () => { + txMock.guest.findUnique.mockResolvedValue(guestWith('UNSPECIFIED', [membership()])); + + const res = await service.processBarcodeCheckIn({ barcode: '100099' }, 'staff-1'); + + expect(res.status).toBe(CheckInStatus.SUCCESS); + expect(res.warnings).toContain('GENDER_MISSING'); + }); + }); }); diff --git a/apps/api/src/modules/check-ins/check-ins.service.ts b/apps/api/src/modules/check-ins/check-ins.service.ts index cc3c05f..7a8fbca 100644 --- a/apps/api/src/modules/check-ins/check-ins.service.ts +++ b/apps/api/src/modules/check-ins/check-ins.service.ts @@ -15,6 +15,7 @@ import { PassType, resolveArrivalEntitlement, VisitType, + womenOnlyAccess, } from '@reform/shared'; import { PrismaService } from '../../prisma/prisma.service'; import { MonitorGateway } from '../monitor/monitor.gateway'; @@ -102,7 +103,7 @@ export class CheckInsService { orderBy: { expirationDate: 'asc' }, }, memberships: { - include: { dues: true }, + include: { dues: true, service: true }, orderBy: { createdAt: 'desc' }, }, }, @@ -193,16 +194,34 @@ export class CheckInsService { warnings.push('MEMBERSHIP_PAUSED'); } + // Csak nőknek szóló tagság / bérlet: férfi vendéggel nem számít (TICKET-42). const entitlement = resolveArrivalEntitlement( { - memberships: guest.memberships ?? [], + guestGender: guest.gender, + memberships: (guest.memberships ?? []).map((m) => ({ + ...m, + womenOnly: m.service?.womenOnly ?? false, + })), passes: guest.passes.map((p) => ({ ...p, serviceCategory: p.service?.category ?? null, + womenOnly: p.service?.womenOnly ?? false, })), }, now, ); + const used = + entitlement.kind === 'CIRCUIT_MEMBERSHIP' + ? entitlement.membership + : entitlement.kind === 'CIRCUIT_PASS' + ? entitlement.pass + : null; + if ( + used && + womenOnlyAccess(guest.gender, used.womenOnly) === 'GENDER_MISSING' + ) { + warnings.push('GENDER_MISSING'); + } const overdue = { overdueAmountHuf: overdueAmountHuf > 0 ? overdueAmountHuf : undefined, overdueDays: maxOverdueDays > 0 ? maxOverdueDays : undefined, diff --git a/apps/api/src/modules/finance/finance.controller.ts b/apps/api/src/modules/finance/finance.controller.ts index 4711668..18ce646 100644 --- a/apps/api/src/modules/finance/finance.controller.ts +++ b/apps/api/src/modules/finance/finance.controller.ts @@ -70,6 +70,14 @@ export class CreateMembershipInputDto { @IsNotEmpty() guestId: string; + @ApiPropertyOptional({ + description: + 'A tagság szolgáltatása; ha hiányzik, a stúdió köredzés (CIRCUIT) szolgáltatása.', + }) + @IsOptional() + @IsString() + serviceId?: string; + @IsInt() @Min(1) monthlyFeeHuf: number; diff --git a/apps/api/src/modules/finance/finance.service.spec.ts b/apps/api/src/modules/finance/finance.service.spec.ts index 472a7c5..cfcc47d 100644 --- a/apps/api/src/modules/finance/finance.service.spec.ts +++ b/apps/api/src/modules/finance/finance.service.spec.ts @@ -58,6 +58,13 @@ describe('FinanceService — havi tagdíj-esedékességek pótlása (TICKET-13)' findMany: vi.fn().mockResolvedValue([]), create: vi.fn().mockResolvedValue(membershipRow()), }, + guest: { + findFirst: vi.fn().mockResolvedValue({ id: 'g1', gender: 'FEMALE' }), + }, + service: { + findMany: vi.fn().mockResolvedValue([]), + findFirst: vi.fn().mockResolvedValue(null), + }, }; service = new FinanceService(prismaMock); }); @@ -154,6 +161,64 @@ describe('FinanceService — havi tagdíj-esedékességek pótlása (TICKET-13)' ]); }); + describe('createMembership: a köredzés szolgáltatás és a csak nőknek szóló korlátozás (TICKET-42)', () => { + const circuit = { + id: 'srv-circuit', + category: 'CIRCUIT', + womenOnly: true, + isActive: true, + createdAt: day('2026-01-01'), + }; + const newerCircuit = { ...circuit, id: 'srv-circuit-2', createdAt: day('2026-06-01') }; + + beforeEach(() => { + setNow('2026-11-05T10:00:00Z'); + prismaMock.service.findMany.mockResolvedValue([newerCircuit, circuit]); + }); + + it('női vendég: a tagság a stúdió (legrégebbi aktív) köredzés szolgáltatásához kötődik', async () => { + await service.createMembership({ guestId: 'g1', monthlyFeeHuf: 22000 }); + + expect(prismaMock.membership.create.mock.calls[0][0].data.serviceId).toBe( + 'srv-circuit', + ); + }); + + it('férfi vendégnek csak nőknek szóló tagság nem adható el: PASS.WOMEN_ONLY', async () => { + prismaMock.guest.findFirst.mockResolvedValue({ id: 'g1', gender: 'MALE' }); + + await expect( + service.createMembership({ guestId: 'g1', monthlyFeeHuf: 22000 }), + ).rejects.toMatchObject({ code: 'PASS.WOMEN_ONLY' }); + expect(prismaMock.membership.create).not.toHaveBeenCalled(); + }); + + it('férfi vendégnek a nem csak nőknek szóló köredzés tagság eladható', async () => { + prismaMock.guest.findFirst.mockResolvedValue({ id: 'g1', gender: 'MALE' }); + prismaMock.service.findMany.mockResolvedValue([{ ...circuit, womenOnly: false }]); + + await service.createMembership({ guestId: 'g1', monthlyFeeHuf: 22000 }); + + expect(prismaMock.membership.create).toHaveBeenCalledTimes(1); + }); + + it('nincs megadva nemnél eladható (a felület figyelmeztet)', async () => { + prismaMock.guest.findFirst.mockResolvedValue({ id: 'g1', gender: 'UNSPECIFIED' }); + + await service.createMembership({ guestId: 'g1', monthlyFeeHuf: 22000 }); + + expect(prismaMock.membership.create).toHaveBeenCalledTimes(1); + }); + + it('ismeretlen vendégnél GUEST.NOT_FOUND', async () => { + prismaMock.guest.findFirst.mockResolvedValue(null); + + await expect( + service.createMembership({ guestId: 'nincs', monthlyFeeHuf: 22000 }), + ).rejects.toMatchObject({ code: 'GUEST.NOT_FOUND' }); + }); + }); + it('szüneteltetés: pótol, rögzíti a szünet napját és törli a szünet utáni érintetlen díjat', async () => { setNow('2026-11-05T10:00:00Z'); txMock.membership.findFirst.mockResolvedValue(membershipRow()); diff --git a/apps/api/src/modules/finance/finance.service.ts b/apps/api/src/modules/finance/finance.service.ts index 8fe803d..0dd07a0 100644 --- a/apps/api/src/modules/finance/finance.service.ts +++ b/apps/api/src/modules/finance/finance.service.ts @@ -20,9 +20,11 @@ import { OverdueSummaryItemDto, PaymentDto, PaymentMethod, + pickCircuitMembershipService, RecordPaymentRequestDto, stopsBilling, TodayDashboardDto, + womenOnlyAccess, } from '@reform/shared'; import { PrismaService } from '../../prisma/prisma.service'; @@ -375,12 +377,41 @@ export class FinanceService { async createMembership( dto: { guestId: string; + serviceId?: string; monthlyFeeHuf: number; startDate?: string; dueDay?: number; }, studioId = DEFAULT_STUDIO_ID, ): Promise { + const guest = await this.prisma.guest.findFirst({ + where: { id: dto.guestId, studioId }, + }); + if (!guest) throw appError('GUEST.NOT_FOUND'); + + // A havi tagság a köredzés szolgáltatáshoz kötődik (TICKET-42), így a csak nőknek szóló + // korlátozás rá is vonatkozik: férfi vendégnek nem adható el. + const service = dto.serviceId + ? await this.prisma.service.findFirst({ + where: { id: dto.serviceId, studioId }, + }) + : pickCircuitMembershipService( + await this.prisma.service.findMany({ + where: { studioId, category: 'CIRCUIT' }, + }), + ); + if (dto.serviceId && !service) { + throw appError('COMMON.NOT_FOUND', { + message: 'A tagsághoz kiválasztott szolgáltatás nem található.', + }); + } + if (womenOnlyAccess(guest.gender, service?.womenOnly) === 'DENIED') { + throw appError('PASS.WOMEN_ONLY', { + message: + 'A köredzés tagság csak nőknek szól, férfi vendégnek nem adható el.', + }); + } + const start = dto.startDate ? new Date(dto.startDate) : new Date(); const dueDay = dto.dueDay ?? 10; const schedule = getMembershipDueSchedule( @@ -393,6 +424,7 @@ export class FinanceService { data: { studioId, guestId: dto.guestId, + serviceId: service?.id ?? null, monthlyFeeHuf: dto.monthlyFeeHuf, startDate: start, dueDay, @@ -407,7 +439,7 @@ export class FinanceService { })), }, }, - include: { dues: true, guest: true }, + include: { dues: true, guest: true, service: true }, }); return this.mapMembership(created); @@ -465,7 +497,7 @@ export class FinanceService { return tx.membership.findUniqueOrThrow({ where: { id: membershipId }, - include: { dues: true, guest: true }, + include: { dues: true, guest: true, service: true }, }); }); @@ -829,6 +861,8 @@ export class FinanceService { studioId: m.studioId, guestId: m.guestId, guestName: m.guest ? `${m.guest.lastName} ${m.guest.firstName}` : undefined, + serviceId: m.serviceId ?? null, + serviceName: m.service?.name ?? null, monthlyFeeHuf: m.monthlyFeeHuf, startDate: m.startDate.toISOString(), dueDay: m.dueDay, diff --git a/apps/api/src/modules/guests/gender.spec.ts b/apps/api/src/modules/guests/gender.spec.ts new file mode 100644 index 0000000..86cdaaa --- /dev/null +++ b/apps/api/src/modules/guests/gender.spec.ts @@ -0,0 +1,180 @@ +import { + FEMALE_FIRST_NAMES, + Gender, + inferGenderFromFirstName, + MALE_FIRST_NAMES, + normalizeFirstName, + pickCircuitMembershipService, + resolveArrivalEntitlement, + womenOnlyAccess, +} from '@reform/shared'; +import { readFileSync } from 'fs'; +import path from 'path'; +import { describe, expect, it } from 'vitest'; + +/** TICKET-42: a vendég neme és a csak nőknek szóló szolgáltatás közös (packages/shared) szabályai. */ +describe('inferGenderFromFirstName', () => { + it('a demóvendégek: Anna, Dóra, Eszter, Zsófia → nő; Gábor, Péter → férfi; „Élő” → nincs megadva', () => { + for (const name of ['Anna', 'Dóra', 'Eszter', 'Zsófia']) { + expect(inferGenderFromFirstName(name)).toBe(Gender.FEMALE); + } + for (const name of ['Gábor', 'Péter']) { + expect(inferGenderFromFirstName(name)).toBe(Gender.MALE); + } + expect(inferGenderFromFirstName('Élő')).toBe(Gender.UNSPECIFIED); + }); + + it('kis- és nagybetű, ékezet és szóközök nem számítanak', () => { + expect(inferGenderFromFirstName(' ZSÓFIA ')).toBe(Gender.FEMALE); + expect(inferGenderFromFirstName('zsofia')).toBe(Gender.FEMALE); + expect(inferGenderFromFirstName('ÁGNES')).toBe(Gender.FEMALE); + expect(inferGenderFromFirstName('lőrinc')).toBe(Gender.MALE); + expect(inferGenderFromFirstName('Tünde')).toBe(Gender.FEMALE); + }); + + it('összetett keresztnévnél az első tag dönt', () => { + expect(inferGenderFromFirstName('Anna Mária')).toBe(Gender.FEMALE); + expect(inferGenderFromFirstName('Anna-Mária')).toBe(Gender.FEMALE); + expect(inferGenderFromFirstName('Péter Pál')).toBe(Gender.MALE); + }); + + it('üres vagy ismeretlen névnél nincs megadva', () => { + expect(inferGenderFromFirstName('')).toBe(Gender.UNSPECIFIED); + expect(inferGenderFromFirstName(null)).toBe(Gender.UNSPECIFIED); + expect(inferGenderFromFirstName('Kovács')).toBe(Gender.UNSPECIFIED); + }); + + it('a két lista normalizált alakú, nincs bennük ismétlődés és közös név', () => { + for (const list of [FEMALE_FIRST_NAMES, MALE_FIRST_NAMES]) { + expect(new Set(list).size).toBe(list.length); + for (const name of list) expect(normalizeFirstName(name)).toBe(name); + } + const male = new Set(MALE_FIRST_NAMES); + expect(FEMALE_FIRST_NAMES.filter((n) => male.has(n))).toEqual([]); + }); + + it('a migráció ugyanazt a névlistát használja, mint a shared függvény', () => { + const sql = readFileSync( + path.resolve( + __dirname, + '../../../prisma/migrations/20261111180000_guest_gender_women_only/migration.sql', + ), + 'utf8', + ); + const namesAfter = (marker: string) => { + const start = sql.indexOf(marker); + const block = sql.slice(start, sql.indexOf(');', start)); + const inList = block.slice(block.indexOf(') IN (')); + return [...inList.matchAll(/'([a-z]+)'/g)].map((m) => m[1]); + }; + expect(namesAfter('-- FEMALE_FIRST_NAMES')).toEqual([...FEMALE_FIRST_NAMES]); + expect(namesAfter('-- MALE_FIRST_NAMES')).toEqual([...MALE_FIRST_NAMES]); + }); +}); + +describe('womenOnlyAccess', () => { + it('nem csak nőknek szóló szolgáltatásnál mindenki mehet', () => { + expect(womenOnlyAccess(Gender.MALE, false)).toBe('ALLOWED'); + expect(womenOnlyAccess(Gender.UNSPECIFIED, null)).toBe('ALLOWED'); + }); + + it('csak nőknek: nő mehet, férfi nem, ismeretlen nemnél figyelmeztetés', () => { + expect(womenOnlyAccess(Gender.FEMALE, true)).toBe('ALLOWED'); + expect(womenOnlyAccess(Gender.MALE, true)).toBe('DENIED'); + expect(womenOnlyAccess(Gender.UNSPECIFIED, true)).toBe('GENDER_MISSING'); + expect(womenOnlyAccess(undefined, true)).toBe('GENDER_MISSING'); + }); +}); + +describe('pickCircuitMembershipService', () => { + it('a CIRCUIT szolgáltatások közül az aktív, azon belül a legrégebbi', () => { + const services = [ + { id: 'hypoxi', category: 'BODY_SHAPING', isActive: true, createdAt: '2026-01-01' }, + { id: 'old-inactive', category: 'CIRCUIT', isActive: false, createdAt: '2025-01-01' }, + { id: 'newer', category: 'CIRCUIT', isActive: true, createdAt: '2026-06-01' }, + { id: 'older', category: 'CIRCUIT', isActive: true, createdAt: '2026-02-01' }, + ]; + expect(pickCircuitMembershipService(services)?.id).toBe('older'); + expect(pickCircuitMembershipService(services.slice(0, 1))).toBeNull(); + }); +}); + +describe('resolveArrivalEntitlement — csak nőknek szóló tagság és bérlet', () => { + const now = new Date('2026-11-11T10:00:00Z'); + const membership = { id: 'mem', status: 'ACTIVE', womenOnly: true }; + const circuitPass = { + id: 'circuit', + type: 'TIME_BASED', + remainingPasses: null, + startDate: '2026-11-01T00:00:00Z', + expirationDate: '2026-11-30T00:00:00Z', + isFrozen: false, + serviceId: 'srv-circuit', + serviceCategory: 'CIRCUIT', + womenOnly: true, + }; + const hypoxiPass = { + ...circuitPass, + id: 'hypoxi', + type: 'OCCASION_BASED', + remainingPasses: 3, + serviceId: 'srv-hypoxi', + serviceCategory: 'BODY_SHAPING', + womenOnly: false, + }; + + it('női vendég: a tagság köredzés belépést ad', () => { + expect( + resolveArrivalEntitlement( + { guestGender: Gender.FEMALE, memberships: [membership], passes: [] }, + now, + ).kind, + ).toBe('CIRCUIT_MEMBERSHIP'); + }); + + it('ismeretlen nem: belépés (a figyelmeztetést a hívó adja)', () => { + expect( + resolveArrivalEntitlement( + { guestGender: Gender.UNSPECIFIED, memberships: [], passes: [circuitPass] }, + now, + ).kind, + ).toBe('CIRCUIT_PASS'); + }); + + it('férfi vendég: a csak nőknek szóló tagság és bérlet nem számít → WOMEN_ONLY', () => { + expect( + resolveArrivalEntitlement( + { guestGender: Gender.MALE, memberships: [membership], passes: [circuitPass] }, + now, + ), + ).toEqual({ kind: 'DENIED', reason: 'WOMEN_ONLY' }); + }); + + it('férfi vendég más érvényes bérlettel: kezelésválasztás csak azzal', () => { + const res = resolveArrivalEntitlement( + { + guestGender: Gender.MALE, + memberships: [membership], + passes: [circuitPass, hypoxiPass], + }, + now, + ); + expect(res.kind).toBe('TREATMENT_SELECTION'); + expect(res.kind === 'TREATMENT_SELECTION' && res.passes.map((p) => p.id)).toEqual([ + 'hypoxi', + ]); + }); + + it('férfi vendég lejárt női bérlettel: nem WOMEN_ONLY, hanem nincs érvényes bérlet', () => { + expect( + resolveArrivalEntitlement( + { + guestGender: Gender.MALE, + memberships: [], + passes: [{ ...circuitPass, expirationDate: '2026-11-05T00:00:00Z' }], + }, + now, + ), + ).toEqual({ kind: 'DENIED', reason: 'NO_VALID_PASS' }); + }); +}); diff --git a/apps/api/src/modules/guests/guests.dto.ts b/apps/api/src/modules/guests/guests.dto.ts index 7557468..5d3463f 100644 --- a/apps/api/src/modules/guests/guests.dto.ts +++ b/apps/api/src/modules/guests/guests.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { DocumentType, GuestStatus, Role } from '@reform/shared'; +import { DocumentType, Gender, GuestStatus, Role } from '@reform/shared'; import { IsDateString, IsEmail, @@ -51,6 +51,14 @@ export class CreateGuestDto { @IsString() nameDay?: string | null; + @ApiProperty({ + enum: Gender, + description: + 'Új vendégnél kötelező; a felület a keresztnévből előtölti (inferGenderFromFirstName).', + }) + @IsEnum(Gender, { message: 'A vendég nemének megadása kötelező.' }) + gender: Gender; + @ApiPropertyOptional({ enum: GuestStatus, default: GuestStatus.ACTIVE }) @IsOptional() @IsEnum(GuestStatus) @@ -98,6 +106,11 @@ export class UpdateGuestDto { @IsString() nameDay?: string | null; + @ApiPropertyOptional({ enum: Gender }) + @IsOptional() + @IsEnum(Gender) + gender?: Gender; + @ApiPropertyOptional({ enum: GuestStatus }) @IsOptional() @IsEnum(GuestStatus) diff --git a/apps/api/src/modules/guests/guests.service.ts b/apps/api/src/modules/guests/guests.service.ts index 586e91f..cef584a 100644 --- a/apps/api/src/modules/guests/guests.service.ts +++ b/apps/api/src/modules/guests/guests.service.ts @@ -6,6 +6,7 @@ import { DEFAULT_STUDIO_ID, DocumentStatus, DocumentType, + Gender, GuestDocumentDto, getPassStatus, GuestDto, @@ -71,6 +72,7 @@ export class GuestsService { }, memberships: { include: { + service: true, dues: { orderBy: { dueDate: 'desc' as const } }, }, orderBy: { createdAt: 'desc' as const }, @@ -190,6 +192,7 @@ export class GuestsService { phone: dto.phone?.trim() || null, birthDate: dto.birthDate ? new Date(dto.birthDate) : null, nameDay: dto.nameDay?.trim() || null, + gender: dto.gender, status: dto.status ?? GuestStatus.ACTIVE, notes: dto.notes?.trim() || null, }, @@ -243,6 +246,7 @@ export class GuestsService { ...(dto.nameDay !== undefined ? { nameDay: dto.nameDay?.trim() || null } : {}), + ...(dto.gender !== undefined ? { gender: dto.gender } : {}), ...(dto.status !== undefined ? { status: dto.status } : {}), ...(dto.notes !== undefined ? { notes: dto.notes?.trim() || null } : {}), }, @@ -585,6 +589,7 @@ export class GuestsService { phone: g.phone, birthDate: g.birthDate ? g.birthDate.toISOString().slice(0, 10) : null, nameDay: g.nameDay ?? null, + gender: (g.gender as Gender) ?? Gender.UNSPECIFIED, status: (g.status as GuestStatus) ?? GuestStatus.ACTIVE, notes: g.notes, isActive: g.isActive, @@ -599,6 +604,8 @@ export class GuestsService { studioId: m.studioId, guestId: m.guestId, guestName: guestFullName, + serviceId: m.serviceId ?? null, + serviceName: m.service?.name ?? null, monthlyFeeHuf: m.monthlyFeeHuf, startDate: m.startDate.toISOString(), dueDay: m.dueDay, diff --git a/apps/api/src/modules/passes/passes-women-only.spec.ts b/apps/api/src/modules/passes/passes-women-only.spec.ts new file mode 100644 index 0000000..66a6545 --- /dev/null +++ b/apps/api/src/modules/passes/passes-women-only.spec.ts @@ -0,0 +1,198 @@ +import { PaymentMethod } from '@reform/shared'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { PassesService } from './passes.service'; + +/** TICKET-42: csak nőknek szóló bérlet eladása és kezeléslevonás férfi / női / ismeretlen nemű vendéggel. */ +describe('PassesService — csak nőknek szóló bérlet és kezelés (TICKET-42)', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let txMock: any; + let passesService: PassesService; + + const circuitService = { + id: 'srv-circuit', + name: 'Női Köredzés (Havi Tagság)', + category: 'CIRCUIT', + womenOnly: true, + }; + const hypoxiService = { + id: 'srv-hypoxi', + name: 'Hypoxi', + category: 'BODY_SHAPING', + womenOnly: false, + }; + const guest = (gender: string) => ({ + id: 'guest-1', + studioId: 'default-studio', + firstName: gender === 'MALE' ? 'Péter' : 'Anna', + lastName: 'Teszt', + barcode: '100099', + gender, + isActive: true, + }); + const template = (service: typeof circuitService | null) => ({ + id: 'tpl-1', + studioId: 'default-studio', + serviceId: service?.id ?? null, + service, + name: service ? 'Havi Korlátlan Köredzés Bérlet' : '10 Alkalmas Bérlet', + type: service ? 'TIME_BASED' : 'OCCASION_BASED', + totalPasses: service ? null : 10, + durationDays: 30, + price: 22000, + isActive: true, + }); + /** Szolgáltatáshoz nem kötött, 10 alkalmas bérlet (bármely kezelésre levonható). */ + const openPass = (service: typeof circuitService | null = null) => ({ + id: 'pass-1', + studioId: 'default-studio', + guestId: 'guest-1', + passTemplateId: 'tpl-1', + serviceId: service?.id ?? null, + service, + type: 'OCCASION_BASED', + totalPasses: 10, + remainingPasses: 6, + startDate: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000), + expirationDate: new Date(Date.now() + 20 * 24 * 60 * 60 * 1000), + usageLogs: Array.from({ length: 4 }, () => ({ delta: -1, reversedAt: null })), + isActive: true, + isFrozen: false, + cancelledAt: null, + passTemplate: { name: '10 Alkalmas Bérlet', serviceId: service?.id ?? null, service }, + }); + + beforeEach(() => { + txMock = { + guest: { findFirst: vi.fn() }, + service: { findFirst: vi.fn() }, + passTemplate: { findFirst: vi.fn() }, + guestPass: { + findFirst: vi.fn(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + create: vi.fn(async ({ data }: { data: any }) => ({ + id: 'new-pass', + ...data, + createdAt: new Date(), + passTemplate: { name: 'Bérlet' }, + })), + update: vi.fn(), + updateMany: vi.fn().mockResolvedValue({ count: 1 }), + }, + passUsageLog: { + findUnique: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({ id: 'usage-1' }), + }, + loggedCheckIn: { create: vi.fn().mockResolvedValue({ id: 'chk-1' }) }, + visit: { create: vi.fn().mockResolvedValue({ id: 'visit-1' }) }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + payment: { create: vi.fn(async ({ data }: { data: any }) => ({ id: 'pay-1', ...data })) }, + paymentAllocation: { create: vi.fn().mockResolvedValue({ id: 'alloc-1' }) }, + }; + const prismaMock = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + $transaction: vi.fn(async (cb: any) => cb(txMock)), + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + passesService = new PassesService(prismaMock as any, { emitCheckInResult: vi.fn() } as any); + }); + + const sell = () => + passesService.sellPass( + { guestId: 'guest-1', passTemplateId: 'tpl-1', paymentMethod: PaymentMethod.CASH }, + 'staff-1', + ); + const deduct = (serviceId: string) => + passesService.deductTreatmentUsage( + { guestId: 'guest-1', passId: 'pass-1', serviceId }, + 'staff-1', + ); + + describe('eladás', () => { + it('női vendégnek a köredzés bérlet eladható', async () => { + txMock.guest.findFirst.mockResolvedValue(guest('FEMALE')); + txMock.passTemplate.findFirst.mockResolvedValue(template(circuitService)); + + const res = await sell(); + + expect(res.paymentId).toBe('pay-1'); + expect(txMock.guestPass.create).toHaveBeenCalledTimes(1); + }); + + it('férfi vendégnek a köredzés bérlet nem adható el: PASS.WOMEN_ONLY, semmi nem rögzül', async () => { + txMock.guest.findFirst.mockResolvedValue(guest('MALE')); + txMock.passTemplate.findFirst.mockResolvedValue(template(circuitService)); + + await expect(sell()).rejects.toMatchObject({ code: 'PASS.WOMEN_ONLY' }); + expect(txMock.guestPass.create).not.toHaveBeenCalled(); + expect(txMock.payment.create).not.toHaveBeenCalled(); + }); + + it('férfi vendégnek a nem csak nőknek szóló bérlet eladható', async () => { + txMock.guest.findFirst.mockResolvedValue(guest('MALE')); + txMock.passTemplate.findFirst.mockResolvedValue(template(null)); + + await expect(sell()).resolves.toMatchObject({ paymentId: 'pay-1' }); + }); + + it('nincs megadva nemnél a köredzés bérlet eladható', async () => { + txMock.guest.findFirst.mockResolvedValue(guest('UNSPECIFIED')); + txMock.passTemplate.findFirst.mockResolvedValue(template(circuitService)); + + await expect(sell()).resolves.toMatchObject({ paymentId: 'pay-1' }); + }); + }); + + describe('kezeléslevonás', () => { + it('női vendég: a csak nőknek szóló kezelés levonható', async () => { + txMock.guest.findFirst.mockResolvedValue(guest('FEMALE')); + txMock.guestPass.findFirst.mockResolvedValue(openPass()); + txMock.service.findFirst.mockResolvedValue(circuitService); + + const res = await deduct('srv-circuit'); + + expect(res.status).toBe('SUCCESS'); + expect(res.warnings).not.toContain('GENDER_MISSING'); + }); + + it('férfi vendég: a csak nőknek szóló kezelés nem vonható le: CHECKIN.WOMEN_ONLY, nincs belépés', async () => { + txMock.guest.findFirst.mockResolvedValue(guest('MALE')); + txMock.guestPass.findFirst.mockResolvedValue(openPass()); + txMock.service.findFirst.mockResolvedValue(circuitService); + + await expect(deduct('srv-circuit')).rejects.toMatchObject({ + code: 'CHECKIN.WOMEN_ONLY', + }); + expect(txMock.loggedCheckIn.create).not.toHaveBeenCalled(); + expect(txMock.passUsageLog.create).not.toHaveBeenCalled(); + }); + + it('férfi vendég: csak nőknek szóló szolgáltatáshoz kötött bérletről sem vonható le', async () => { + txMock.guest.findFirst.mockResolvedValue(guest('MALE')); + txMock.guestPass.findFirst.mockResolvedValue(openPass(circuitService)); + txMock.service.findFirst.mockResolvedValue(circuitService); + + await expect(deduct('srv-circuit')).rejects.toMatchObject({ + code: 'CHECKIN.WOMEN_ONLY', + }); + }); + + it('férfi vendég: más kezelés levonható', async () => { + txMock.guest.findFirst.mockResolvedValue(guest('MALE')); + txMock.guestPass.findFirst.mockResolvedValue(openPass()); + txMock.service.findFirst.mockResolvedValue(hypoxiService); + + await expect(deduct('srv-hypoxi')).resolves.toMatchObject({ status: 'SUCCESS' }); + }); + + it('nincs megadva nem: levonható, de GENDER_MISSING figyelmeztetéssel', async () => { + txMock.guest.findFirst.mockResolvedValue(guest('UNSPECIFIED')); + txMock.guestPass.findFirst.mockResolvedValue(openPass()); + txMock.service.findFirst.mockResolvedValue(circuitService); + + const res = await deduct('srv-circuit'); + + expect(res.status).toBe('SUCCESS'); + expect(res.warnings).toContain('GENDER_MISSING'); + }); + }); +}); diff --git a/apps/api/src/modules/passes/passes.dto.ts b/apps/api/src/modules/passes/passes.dto.ts index f9fcd30..de7dec4 100644 --- a/apps/api/src/modules/passes/passes.dto.ts +++ b/apps/api/src/modules/passes/passes.dto.ts @@ -31,6 +31,14 @@ export class CreateServiceDto { @IsInt() @Min(0) priceHuf: number; + + @ApiPropertyOptional({ + default: false, + description: 'Csak nőknek szól (pl. női köredzés): férfi vendégnek nem adható el.', + }) + @IsOptional() + @IsBoolean() + womenOnly?: boolean; } export class CreatePassTemplateDto { diff --git a/apps/api/src/modules/passes/passes.service.ts b/apps/api/src/modules/passes/passes.service.ts index c145bc1..9855019 100644 --- a/apps/api/src/modules/passes/passes.service.ts +++ b/apps/api/src/modules/passes/passes.service.ts @@ -22,6 +22,7 @@ import { PassUsageLogDto, ServiceCategory, ServiceDto, + womenOnlyAccess, } from '@reform/shared'; import { PrismaService } from '../../prisma/prisma.service'; import { MonitorGateway } from '../monitor/monitor.gateway'; @@ -69,6 +70,7 @@ export class PassesService { category: s.category as unknown as ServiceCategory, durationMinutes: s.durationMinutes, priceHuf: s.priceHuf, + womenOnly: s.womenOnly, isActive: s.isActive, createdAt: s.createdAt.toISOString(), })); @@ -85,6 +87,7 @@ export class PassesService { category: dto.category, durationMinutes: dto.durationMinutes, priceHuf: dto.priceHuf, + womenOnly: dto.womenOnly ?? false, }, }); @@ -95,6 +98,7 @@ export class PassesService { category: created.category as unknown as ServiceCategory, durationMinutes: created.durationMinutes, priceHuf: created.priceHuf, + womenOnly: created.womenOnly, isActive: created.isActive, createdAt: created.createdAt.toISOString(), }; @@ -118,6 +122,7 @@ export class PassesService { studioId: t.studioId, serviceId: t.serviceId, serviceName: t.service?.name ?? null, + womenOnly: t.service?.womenOnly ?? false, name: t.name, type: t.type as unknown as PassType, totalPasses: t.totalPasses, @@ -155,6 +160,7 @@ export class PassesService { studioId: created.studioId, serviceId: created.serviceId, serviceName: created.service?.name ?? null, + womenOnly: created.service?.womenOnly ?? false, name: created.name, type: created.type as unknown as PassType, totalPasses: created.totalPasses, @@ -195,6 +201,7 @@ export class PassesService { studioId: updated.studioId, serviceId: updated.serviceId, serviceName: updated.service?.name ?? null, + womenOnly: updated.service?.womenOnly ?? false, name: updated.name, type: updated.type as unknown as PassType, totalPasses: updated.totalPasses, @@ -229,6 +236,12 @@ export class PassesService { message: 'A kiválasztott bérletsablon nem található vagy inaktív.', }); } + // Csak nőknek szóló bérlet férfi vendégnek nem adható el (TICKET-42). + if ( + womenOnlyAccess(guest.gender, template.service?.womenOnly) === 'DENIED' + ) { + throw appError('PASS.WOMEN_ONLY'); + } const startDate = dto.startDate ? new Date(dto.startDate) : new Date(); const expirationDate = new Date( @@ -361,7 +374,11 @@ export class PassesService { const pass = await tx.guestPass.findFirst({ where: { id: dto.passId, guestId: guest.id, studioId }, - include: { passTemplate: true, usageLogs: true }, + include: { + passTemplate: { include: { service: true } }, + service: true, + usageLogs: true, + }, }); if (!pass) { throw appError('PASS.NOT_FOUND', { @@ -390,6 +407,14 @@ export class PassesService { throw appError('PASS.SERVICE_MISMATCH'); } + // Csak nőknek szóló kezelés vagy bérlet: férfi vendég nem léptethető be vele (TICKET-42). + const womenOnly = womenOnlyAccess( + guest.gender, + service.womenOnly || + !!(pass.service ?? pass.passTemplate.service)?.womenOnly, + ); + if (womenOnly === 'DENIED') throw appError('CHECKIN.WOMEN_ONLY'); + const remaining = computeRemainingPasses( pass.totalPasses, pass.usageLogs ?? [], @@ -422,6 +447,7 @@ export class PassesService { ); const warnings: CheckInWarningCode[] = []; + if (womenOnly === 'GENDER_MISSING') warnings.push('GENDER_MISSING'); if (pass.type === 'OCCASION_BASED' && typeof newRemaining === 'number') { if (newRemaining === 0) { warnings.push('DEPLETED_PASS'); diff --git a/apps/web/src/features/deduct-treatment/model/treatmentPreselection.test.ts b/apps/web/src/features/deduct-treatment/model/treatmentPreselection.test.ts index f6aade8..f2c1031 100644 --- a/apps/web/src/features/deduct-treatment/model/treatmentPreselection.test.ts +++ b/apps/web/src/features/deduct-treatment/model/treatmentPreselection.test.ts @@ -14,6 +14,7 @@ const service = (id: string, isActive = true): ServiceDto => ({ category: ServiceCategory.BODY_SHAPING, durationMinutes: 30, priceHuf: 9500, + womenOnly: false, isActive, createdAt: '2026-01-01T00:00:00.000Z', }); diff --git a/apps/web/src/features/deduct-treatment/ui/DeductTreatmentModal.tsx b/apps/web/src/features/deduct-treatment/ui/DeductTreatmentModal.tsx index 74a7ec1..c96a2fc 100644 --- a/apps/web/src/features/deduct-treatment/ui/DeductTreatmentModal.tsx +++ b/apps/web/src/features/deduct-treatment/ui/DeductTreatmentModal.tsx @@ -4,6 +4,7 @@ import { PassStatus, PassType, ServiceDto, + womenOnlyAccess, } from '@reform/shared'; import React, { useEffect, useId, useState } from 'react'; import { @@ -99,11 +100,19 @@ export const DeductTreatmentModal: React.FC = ({ const selectedGuest = guests.find((g) => g.id === selectedGuestId) ?? null; + // Csak nőknek szóló kezelés és ilyen szolgáltatáshoz kötött bérlet férfi vendéggel nem választható + // (TICKET-42, a szerver is ellenőrzi). + const isWomenOnly = (serviceId: string | null | undefined) => + !!services.find((s) => s.id === serviceId)?.womenOnly; + const isBlocked = (serviceId: string | null | undefined) => + womenOnlyAccess(selectedGuest?.gender, isWomenOnly(serviceId)) === 'DENIED'; + // A szerver szabálya: csak aktív (számolt állapot) bérlet, és ha szolgáltatáshoz kötött, csak arra a kezelésre. const activePasses = (selectedGuest?.passes ?? []).filter( (p) => p.status === PassStatus.ACTIVE && - passCoversService(p.serviceId, selectedServiceId), + passCoversService(p.serviceId, selectedServiceId) && + !isBlocked(p.serviceId), ); useEffect(() => { @@ -124,8 +133,14 @@ export const DeductTreatmentModal: React.FC = ({ const selectedPass = activePasses.find((p) => p.id === selectedPassId); const selectedService = treatmentServices.find( - (s) => s.id === selectedServiceId, + (s) => s.id === selectedServiceId && !isBlocked(s.id), ); + const genderMissing = + !!selectedGuest && + womenOnlyAccess( + selectedGuest.gender, + isWomenOnly(selectedService?.id) || isWomenOnly(selectedPass?.serviceId), + ) === 'GENDER_MISSING'; const filteredGuests = guests.filter((g) => { const q = guestSearch.trim().toLowerCase(); @@ -261,6 +276,7 @@ export const DeductTreatmentModal: React.FC = ({ key={srv.id} variant="secondary" aria-pressed={srv.id === selectedServiceId} + disabled={isBlocked(srv.id)} onClick={() => setSelectedServiceId(srv.id)} className={`${optionClassName} flex-col items-start gap-0.5 p-3 text-left`} > @@ -268,8 +284,9 @@ export const DeductTreatmentModal: React.FC = ({ {srv.name} - {srv.durationMinutes} perc,{' '} - {srv.priceHuf.toLocaleString('hu-HU')} Ft + {isBlocked(srv.id) + ? 'Csak nőknek, férfi vendéggel nem választható' + : `${srv.durationMinutes} perc, ${srv.priceHuf.toLocaleString('hu-HU')} Ft`} ))} @@ -382,6 +399,15 @@ export const DeductTreatmentModal: React.FC = ({ />
    + {genderMissing && ( + + + Ez a kezelés csak nőknek szól, de a vendég neme nincs + megadva. Pótold a vendégadatlapon. + + + )} + {selectedGuest && selectedPass && selectedService && ( diff --git a/apps/web/src/features/sell-pass/ui/SellPassModal.tsx b/apps/web/src/features/sell-pass/ui/SellPassModal.tsx index fceb257..fb3deac 100644 --- a/apps/web/src/features/sell-pass/ui/SellPassModal.tsx +++ b/apps/web/src/features/sell-pass/ui/SellPassModal.tsx @@ -3,10 +3,13 @@ import { PassTemplateDto, PassType, PaymentMethod, + womenOnlyAccess, } from '@reform/shared'; import { Banknote, Building2, CreditCard } from 'lucide-react'; import React, { useEffect, useId, useState } from 'react'; import { + Alert, + AlertDescription, Button, Dialog, DialogBody, @@ -60,6 +63,10 @@ export const SellPassModal: React.FC = ({ const templatesLabelId = useId(); const methodsLabelId = useId(); const activeTemplates = templates.filter((t) => t.isActive); + // Csak nőknek szóló bérlet férfi vendégnek nem adható el (TICKET-42, a szerver is ellenőrzi). + const isBlocked = (tpl: PassTemplateDto) => + womenOnlyAccess(guest?.gender, tpl.womenOnly) === 'DENIED'; + const sellableTemplates = activeTemplates.filter((t) => !isBlocked(t)); const [selectedTemplateId, setSelectedTemplateId] = useState(''); const [paymentMethod, setPaymentMethod] = useState( PaymentMethod.POS_CARD, @@ -70,24 +77,28 @@ export const SellPassModal: React.FC = ({ if (!isOpen) return; if ( preselectedTemplateId && - activeTemplates.some((t) => t.id === preselectedTemplateId) + sellableTemplates.some((t) => t.id === preselectedTemplateId) ) { setSelectedTemplateId(preselectedTemplateId); - } else if (activeTemplates.length > 0) { - setSelectedTemplateId(activeTemplates[0].id); + } else { + setSelectedTemplateId(sellableTemplates[0]?.id ?? ''); } setNote(''); - }, [isOpen, preselectedTemplateId, templates]); + }, [isOpen, preselectedTemplateId, templates, guest?.id]); if (!isOpen || !guest) return null; - const selectedTemplate = activeTemplates.find( + const selectedTemplate = sellableTemplates.find( (t) => t.id === selectedTemplateId, ); + const genderMissing = + !!selectedTemplate && + womenOnlyAccess(guest.gender, selectedTemplate.womenOnly) === + 'GENDER_MISSING'; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (!selectedTemplateId) return; + if (!selectedTemplate) return; onConfirmSell({ guestId: guest.id, passTemplateId: selectedTemplateId, @@ -121,12 +132,18 @@ export const SellPassModal: React.FC = ({ key={tpl.id} variant="secondary" aria-pressed={tpl.id === selectedTemplateId} + disabled={isBlocked(tpl)} onClick={() => setSelectedTemplateId(tpl.id)} className={`${optionClassName} flex-col items-start gap-1 p-3 text-left`} > {tpl.name} + {isBlocked(tpl) && ( + + Csak nőknek, férfi vendégnek nem adható el + + )} {tpl.type === PassType.TIME_BASED ? 'Korlátlan belépés' @@ -141,6 +158,15 @@ export const SellPassModal: React.FC = ({ + {genderMissing && ( + + + Ez a bérlet csak nőknek szól, de a vendég neme nincs megadva. + Pótold a vendégadatlapon. + + + )} +

    = ({ diff --git a/apps/web/src/pages/reception/model/releaseNotes.ts b/apps/web/src/pages/reception/model/releaseNotes.ts index 293255f..61594ed 100644 --- a/apps/web/src/pages/reception/model/releaseNotes.ts +++ b/apps/web/src/pages/reception/model/releaseNotes.ts @@ -96,6 +96,21 @@ export const getJiraIssueUrl = (jiraKey: string): string => `${JIRA_BASE_URL}/browse/${encodeURIComponent(jiraKey)}`; export const RELEASE_NOTES: ReleaseNote[] = [ + { + id: 'v0.3-feature-guest-gender-women-only', + version: '0.3', + date: '2026-11-11', + kind: 'NEW_FEATURE', + title: 'Vendég neme, női köredzés', + summary: 'Vendég neme; a női köredzés csak női vendégnek adható.', + details: [ + 'Új vendég felvételekor a nem kötelező; a rendszer a keresztnévből előtölti, de átírható. A meglévő vendégeknél a keresztnév alapján töltöttük ki, amit nem ismert fel, ott „Nincs megadva” áll: ezt az adatlapon pótold.', + 'A csak nőknek szóló szolgáltatás (női köredzés) bérlete és havi tagsága férfi vendégnek nem adható el, és vele nem léptethető be; az eladásnál és a kezelésválasztóban ezek nem választhatók.', + '„Nincs megadva” nemnél az eladás és a belépés engedélyezett, de figyelmeztetést kapsz, hogy pótold. Új szolgáltatásnál a „Csak nőknek” jelölővel állíthatod be a korlátozást.', + ], + link: { type: 'receptionTab', tab: 'guests', label: 'Vendégkartoték megnyitása' }, + jiraKey: 'TICKET-42', + }, { id: 'v0.3-fix-pass-freeze-calendar-days', version: '0.3', diff --git a/apps/web/src/pages/reception/ui/ReceptionPage.tsx b/apps/web/src/pages/reception/ui/ReceptionPage.tsx index e253d34..30a73e0 100644 --- a/apps/web/src/pages/reception/ui/ReceptionPage.tsx +++ b/apps/web/src/pages/reception/ui/ReceptionPage.tsx @@ -1,6 +1,7 @@ import { CheckInResultPayload, CheckInStatus, + Gender, GuestDto, TreatmentSelectionOption, VisitType, @@ -355,6 +356,7 @@ export const ReceptionPage: React.FC = () => { phone?: string; birthDate?: string | null; nameDay?: string | null; + gender: Gender; notes?: string; }) => { if (data.id) { diff --git a/apps/web/src/pages/reception/ui/passes/PassesTab.tsx b/apps/web/src/pages/reception/ui/passes/PassesTab.tsx index 6bdfa81..701e3af 100644 --- a/apps/web/src/pages/reception/ui/passes/PassesTab.tsx +++ b/apps/web/src/pages/reception/ui/passes/PassesTab.tsx @@ -11,8 +11,10 @@ import { api } from '@/shared/api'; import { notify } from '@/shared/lib/notify'; import { cn } from '@/shared/lib/utils'; import { + Badge, Button, Card, + Checkbox, CardContent, CardDescription, CardHeader, @@ -94,7 +96,16 @@ const serviceColumns: ResponsiveTableColumn[] = [ { key: 'category', header: 'Kategória', - cell: (s) => SERVICE_CATEGORY_LABELS[s.category], + cell: (s) => ( + + {SERVICE_CATEGORY_LABELS[s.category]} + {s.womenOnly && ( + + Csak nőknek + + )} + + ), }, { key: 'duration', @@ -281,6 +292,8 @@ const ServicesCard: React.FC<{ ); const [duration, setDuration] = useState(30); const [price, setPrice] = useState(7500); + const [womenOnly, setWomenOnly] = useState(false); + const womenOnlyId = useId(); const createServiceMutation = useMutation({ mutationFn: api.createService, @@ -290,6 +303,7 @@ const ServicesCard: React.FC<{ `${srv.name} (${srv.durationMinutes} perc)`, ); setName(''); + setWomenOnly(false); onRefreshAll(); }, }); @@ -320,6 +334,7 @@ const ServicesCard: React.FC<{ category, durationMinutes: duration, priceHuf: price, + womenOnly, }); }} className="grid grid-cols-1 gap-3.5 sm:grid-cols-2" @@ -379,6 +394,29 @@ const ServicesCard: React.FC<{ /> )} +
    +
    + setWomenOnly(checked === true)} + aria-describedby={`${womenOnlyId}-hint`} + /> + +
    +

    + Férfi vendégnek nem adható el ilyen bérlet vagy tagság, és vele + nem léptethető be (pl. női köredzés). +

    +
    diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4dbd2cd..4d13d04 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -48,6 +48,7 @@ A szabályokat a [Steiger](https://github.com/feature-sliced/steiger) linter ell 2. Aktív havi tagság, vagy köredzésre érvényes `TIME_BASED` bérlet (nincs szolgáltatáshoz kötve, vagy a szolgáltatása `CIRCUIT` kategóriájú) → köredzés belépés, 0 levonás. 3. Különben, ha van érvényes bérlet (alkalmas, vagy nem köredzés szolgáltatáshoz kötött) → `TREATMENT_SELECTION_REQUIRED`: a beolvasás nem rögzít belépést és nem küld monitor-eseményt; a recepción a kezelésválasztó nyílik meg (a választható bérletek a **leghamarabb lejáróval** kezdődnek), a levonás és az üdvözlés a megerősítéskor (`deductTreatmentUsage`) történik. A beolvasás soha nem von le alkalmat. 4. Semmi érvényes → elutasítás (`DENIED`, naplózva). +- **Csak nőknek szóló szolgáltatás (TICKET-42):** `Service.womenOnly` (női köredzés); a havi tagság a köredzés szolgáltatáshoz kötődik (`Membership.serviceId`, `pickCircuitMembershipService`). A döntés egyetlen forrása a `womenOnlyAccess(Guest.gender, womenOnly)` (`packages/shared/src/gender.ts`): férfi vendégnek ilyen bérlet / tagság nem adható el (`PASS.WOMEN_ONLY`), kezeléslevonás nem rögzíthető (`CHECKIN.WOMEN_ONLY`), a beolvasásnál pedig a `resolveArrivalEntitlement` nem veszi figyelembe ezeket (ha emiatt nem marad semmi: `DENIED`, `WOMEN_ONLY` ok). „Nincs megadva” nemnél engedélyezett, de a recepció `GENDER_MISSING` figyelmeztetést kap. A nem előtöltése: `inferGenderFromFirstName` (ugyanez a névlista a `guest_gender_women_only` migrációban). ### ADR-04: Dual-Mode Vendégmonitor (`BroadcastChannel` + `Socket.io` + `DisplayDevice`) - **Másodlagos Monitor (Azonos PC):** A böngésző natív `BroadcastChannel('reform_monitor_channel')` csatornáján `< 1 ms` késleltetéssel kapja meg a beléptetési és sztornó eseményeket. diff --git a/packages/shared/src/contracts/index.ts b/packages/shared/src/contracts/index.ts index ce6c813..ad21149 100644 --- a/packages/shared/src/contracts/index.ts +++ b/packages/shared/src/contracts/index.ts @@ -2,6 +2,7 @@ import { CheckInStatus, DocumentStatus, DocumentType, + Gender, GuestStatus, MembershipDueStatus, MembershipStatus, @@ -58,6 +59,8 @@ export interface ServiceDto { category: ServiceCategory; durationMinutes: number; priceHuf: number; + /** Csak nőknek szól (pl. női köredzés): férfi vendégnek nem adható el, vele nem léptethető be. */ + womenOnly: boolean; isActive: boolean; createdAt: string; } @@ -67,6 +70,8 @@ export interface PassTemplateDto { studioId: string; serviceId?: string | null; serviceName?: string | null; + /** A sablon szolgáltatása csak nőknek szól (`ServiceDto.womenOnly`). */ + womenOnly?: boolean; name: string; type: PassType; totalPasses: number | null; @@ -144,6 +149,9 @@ export interface MembershipDto { studioId: string; guestId: string; guestName?: string; + /** A tagság szolgáltatása (a stúdió köredzése); a csak nőknek szóló korlátozás innen jön. */ + serviceId?: string | null; + serviceName?: string | null; monthlyFeeHuf: number; startDate: string; dueDay: number; @@ -287,6 +295,7 @@ export interface GuestDto { phone: string | null; birthDate: string | null; nameDay: string | null; // MM-DD format e.g. "07-26" + gender: Gender; status: GuestStatus; notes: string | null; isActive: boolean; diff --git a/packages/shared/src/enums.ts b/packages/shared/src/enums.ts index 0843a6c..8de3cb3 100644 --- a/packages/shared/src/enums.ts +++ b/packages/shared/src/enums.ts @@ -42,6 +42,13 @@ export enum GuestStatus { PAUSED = 'PAUSED', } +/** A vendég neme (TICKET-42); a csak nőknek szóló szolgáltatásokhoz kell. */ +export enum Gender { + FEMALE = 'FEMALE', + MALE = 'MALE', + UNSPECIFIED = 'UNSPECIFIED', +} + export enum ServiceCategory { CIRCUIT = 'CIRCUIT', COSMETIC = 'COSMETIC', @@ -98,7 +105,9 @@ export type CheckInWarningCode = | 'OVERDUE_DUES' | 'MEMBERSHIP_PAUSED' | 'EXPIRED_PASS' - | 'DEPLETED_PASS'; + | 'DEPLETED_PASS' + /** Csak nőknek szóló szolgáltatás, de a vendég neme nincs megadva: a recepció pótolja (TICKET-42). */ + | 'GENDER_MISSING'; export type MonitorEventMode = 'CIRCUIT_GREETING' | 'TREATMENT_GREETING'; diff --git a/packages/shared/src/errors/catalog.ts b/packages/shared/src/errors/catalog.ts index cc5758a..32a5ef8 100644 --- a/packages/shared/src/errors/catalog.ts +++ b/packages/shared/src/errors/catalog.ts @@ -63,11 +63,13 @@ export const APP_ERROR_CATALOG = { 'PASS.USAGE_NOT_FOUND': { status: 404, message: 'A kezelési levonás nem található.', action: 'notify' }, 'PASS.USAGE_ALREADY_REVERSED': { status: 400, message: 'Ez a levonás már korábban vissza lett vonva.', action: 'notify' }, 'PASS.USAGE_NOT_REVERSIBLE': { status: 400, message: 'Csak alkalomlevonási tétel vonható vissza.', action: 'notify' }, + 'PASS.WOMEN_ONLY': { status: 400, message: 'Ez a bérlet vagy tagság csak nőknek szól, férfi vendégnek nem adható el.', action: 'notify' }, // Beléptetés 'CHECKIN.GUEST_NOT_FOUND': { status: 400, message: 'Nem található aktív vendég a megadott vonalkóddal.', action: 'notify' }, 'CHECKIN.BALANCE_CHANGED': { status: 409, message: 'A bérlet egyenlege időközben módosult. Kérjük, próbáld újra!', action: 'reload' }, 'CHECKIN.NOT_FOUND': { status: 404, message: 'A beléptetési naplóbejegyzés nem található.', action: 'notify' }, + 'CHECKIN.WOMEN_ONLY': { status: 400, message: 'Ez a szolgáltatás csak nőknek szól, férfi vendég nem léptethető be vele.', action: 'notify' }, 'CHECKIN.NOT_REVOCABLE': { status: 400, message: 'Ez a beléptetés már sztornózva van, vagy nem sikeres beléptetés, ezért nem sztornózható.', action: 'notify' }, // Havi tagság diff --git a/packages/shared/src/gender.ts b/packages/shared/src/gender.ts new file mode 100644 index 0000000..cfe900e --- /dev/null +++ b/packages/shared/src/gender.ts @@ -0,0 +1,118 @@ +import { Gender, ServiceCategory } from './enums'; + +/** A nem felirata a felületen. */ +export const GENDER_LABELS: Record = { + [Gender.FEMALE]: 'Nő', + [Gender.MALE]: 'Férfi', + [Gender.UNSPECIFIED]: 'Nincs megadva', +}; + +/** + * Gyakori magyar női keresztnevek, a `normalizeFirstName` alakjában (kisbetű, ékezet nélkül). + * Ugyanez a lista van a `guest_gender_women_only` migrációban (a meglévő vendégek kitöltése); + * az egyezést az API `gender.spec.ts` tesztje ellenőrzi, ezért a kettőt együtt módosítsd. + */ +export const FEMALE_FIRST_NAMES: readonly string[] = [ + 'adel', 'adrienn', 'agnes', 'agota', 'alexandra', 'aliz', 'amalia', 'andrea', 'angela', + 'aniko', 'anita', 'anna', 'aranka', 'barbara', 'beata', 'beatrix', 'bernadett', 'bettina', + 'bianka', 'boglarka', 'borbala', 'boroka', 'brigitta', 'cecilia', 'cintia', 'csenge', + 'csilla', 'dalma', 'diana', 'dominika', 'dora', 'dorina', 'dorka', 'dorottya', 'edina', + 'edit', 'eleonora', 'ella', 'emese', 'emilia', 'emma', 'eniko', 'erika', 'erzsebet', + 'eszter', 'etelka', 'eva', 'evelin', 'fanni', 'flora', 'franciska', 'fruzsina', + 'gabriella', 'georgina', 'gizella', 'greta', 'gyongyi', 'hajnalka', 'hanna', 'hedvig', + 'helga', 'henrietta', 'ibolya', 'ildiko', 'ilona', 'iren', 'irma', 'ivett', 'izabella', + 'janka', 'jazmin', 'johanna', 'jolan', 'judit', 'julia', 'julianna', 'kamilla', + 'karolina', 'kata', 'katalin', 'kinga', 'kincso', 'kitti', 'klara', 'klaudia', 'kornelia', + 'kriszta', 'krisztina', 'lara', 'laura', 'lea', 'lena', 'lenke', 'lili', 'liliana', + 'lilla', 'linda', 'livia', 'luca', 'lujza', 'magdolna', 'maja', 'margit', 'maria', + 'mariann', 'marianna', 'marietta', 'marta', 'melinda', 'mira', 'monika', 'natalia', + 'nikoletta', 'nikolett', 'noemi', 'nora', 'olga', 'orsolya', 'panna', 'patricia', 'petra', + 'piroska', 'ramona', 'rebeka', 'regina', 'reka', 'renata', 'rita', 'roza', 'rozalia', + 'sara', 'sarolta', 'szabina', 'szilvia', 'szofia', 'szonja', 'tamara', 'terez', + 'teodora', 'timea', 'tunde', 'valentina', 'valeria', 'vanda', 'vera', 'veronika', + 'viktoria', 'vilma', 'viola', 'virag', 'vivien', 'zita', 'zoe', 'zsanett', 'zsofi', + 'zsofia', 'zsuzsa', 'zsuzsanna', +]; + +/** Gyakori magyar férfi keresztnevek, a `normalizeFirstName` alakjában (lásd `FEMALE_FIRST_NAMES`). */ +export const MALE_FIRST_NAMES: readonly string[] = [ + 'abel', 'adam', 'adrian', 'akos', 'albert', 'alex', 'andor', 'andras', 'antal', 'arnold', + 'aron', 'arpad', 'attila', 'balazs', 'balint', 'barna', 'barnabas', 'bela', 'bence', + 'benedek', 'benjamin', 'bertalan', 'botond', 'csaba', 'csongor', 'daniel', 'david', + 'denes', 'dezso', 'dominik', 'domonkos', 'ede', 'elek', 'elemer', 'emil', 'endre', 'erik', + 'erno', 'ervin', 'ferenc', 'gabor', 'gaspar', 'gellert', 'gergely', 'gergo', 'geza', + 'gusztav', 'gyorgy', 'gyula', 'henrik', 'hunor', 'ignac', 'imre', 'istvan', 'ivan', + 'jakab', 'janos', 'jeno', 'jozsef', 'kalman', 'karoly', 'kende', 'kevin', 'kolos', + 'kornel', 'kristof', 'krisztian', 'lajos', 'laszlo', 'levente', 'lorand', 'lorant', + 'lorinc', 'marcell', 'mario', 'mark', 'martin', 'marton', 'mate', 'matyas', 'menyhert', + 'mihaly', 'miklos', 'milan', 'nandor', 'nimrod', 'noel', 'norbert', 'oliver', 'otto', + 'pal', 'patrik', 'peter', 'rezso', 'richard', 'robert', 'roland', 'rudolf', 'samuel', + 'sandor', 'sebestyen', 'simon', 'szabolcs', 'szilard', 'szilveszter', 'tamas', 'tibor', + 'tivadar', 'vencel', 'viktor', 'vilmos', 'vince', 'zalan', 'zoltan', 'zsigmond', 'zsolt', + 'zsombor', +]; + +const FEMALE_SET = new Set(FEMALE_FIRST_NAMES); +const MALE_SET = new Set(MALE_FIRST_NAMES); + +/** + * A keresztnév első tagja kisbetűvel, ékezet nélkül („Anna-Mária” → „anna”, „Zsófia” → „zsofia”). + * A migráció SQL-je ugyanígy normalizál (`regexp_replace` + `lower` + `translate`). + */ +export function normalizeFirstName(firstName: string | null | undefined): string { + const first = (firstName ?? '').trim().split(/[\s-]+/)[0] ?? ''; + return first + .toLocaleLowerCase('hu-HU') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, ''); +} + +/** + * Nem a keresztnévből (a `Guest.firstName` mezőből — a vezetéknév külön mezőben van), a gyakori + * magyar keresztnevek listája alapján; amit nem ismer fel, az `UNSPECIFIED`. Csak előtöltés: + * a recepciós átírhatja. + */ +export function inferGenderFromFirstName(firstName: string | null | undefined): Gender { + const key = normalizeFirstName(firstName); + if (FEMALE_SET.has(key)) return Gender.FEMALE; + if (MALE_SET.has(key)) return Gender.MALE; + return Gender.UNSPECIFIED; +} + +export type WomenOnlyAccess = 'ALLOWED' | 'DENIED' | 'GENDER_MISSING'; + +/** + * Csak nőknek szóló bérlet / tagság / szolgáltatás (TICKET-42), egyetlen szabály az API-n, a mockban + * és a felületen: férfi vendégnek nem adható el és vele nem léptethető be (`DENIED`); ha a nem nincs + * megadva, engedélyezett, de a recepciónak figyelmeztetni kell (`GENDER_MISSING`). + */ +export function womenOnlyAccess( + gender: Gender | string | null | undefined, + womenOnly: boolean | null | undefined, +): WomenOnlyAccess { + if (!womenOnly) return 'ALLOWED'; + if (gender === Gender.MALE) return 'DENIED'; + if (gender === Gender.FEMALE) return 'ALLOWED'; + return 'GENDER_MISSING'; +} + +/** + * A havi köredzés tagság szolgáltatása (`Membership.serviceId`): a stúdió köredzés (CIRCUIT) + * szolgáltatása, az aktív elöl, azon belül a legrégebbi. A migráció ugyanezt a sorrendet követi. + */ +export function pickCircuitMembershipService< + S extends { + category: ServiceCategory | string; + isActive: boolean; + createdAt: Date | string; + }, +>(services: readonly S[]): S | null { + const circuit = services + .filter((s) => s.category === ServiceCategory.CIRCUIT) + .sort( + (a, b) => + Number(b.isActive) - Number(a.isActive) || + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); + return circuit[0] ?? null; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0bb4478..9fd1d4b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -6,3 +6,4 @@ export * from './errors'; export * from './membership-dues'; export * from './birthday'; export * from './pass-rules'; +export * from './gender'; diff --git a/packages/shared/src/pass-rules.ts b/packages/shared/src/pass-rules.ts index b51318d..393044c 100644 --- a/packages/shared/src/pass-rules.ts +++ b/packages/shared/src/pass-rules.ts @@ -1,5 +1,6 @@ -import { MembershipStatus, PassStatus, PassType, ServiceCategory } from './enums'; -import type { AppErrorCode } from './errors/catalog'; +import { Gender, MembershipStatus, PassStatus, PassType, ServiceCategory } from './enums'; +import { APP_ERROR_CATALOG, type AppErrorCode } from './errors/catalog'; +import { womenOnlyAccess } from './gender'; import { STUDIO_TIME_ZONE } from './membership-dues'; @@ -105,6 +106,15 @@ export interface ArrivalPassInput extends PassStatusInput { serviceId?: string | null; /** A bérlet szolgáltatásának kategóriája (szolgáltatáshoz kötött bérletnél). */ serviceCategory?: ServiceCategory | string | null; + /** A bérlet szolgáltatása csak nőknek szól (TICKET-42). */ + womenOnly?: boolean | null; +} + +/** A beolvasáskori döntéshez szükséges tagságadatok. */ +export interface ArrivalMembershipInput { + status: MembershipStatus | string; + /** A tagság szolgáltatása (a köredzés) csak nőknek szól (TICKET-42). */ + womenOnly?: boolean | null; } /** @@ -122,10 +132,15 @@ export function passGrantsCircuitEntry(pass: { ); } -export type ArrivalDeniedReason = 'PASS_FROZEN' | 'MEMBERSHIP_PAUSED' | 'NO_VALID_PASS'; +export type ArrivalDeniedReason = + | 'WOMEN_ONLY' + | 'PASS_FROZEN' + | 'MEMBERSHIP_PAUSED' + | 'NO_VALID_PASS'; /** Az elutasított beolvasás oka a recepciónak (a monitorra nem kerül). */ export const ARRIVAL_DENIED_MESSAGES: Record = { + WOMEN_ONLY: APP_ERROR_CATALOG['CHECKIN.WOMEN_ONLY'].message, PASS_FROZEN: 'A vendég bérlete jelenleg fagyasztva van (szünetel).', MEMBERSHIP_PAUSED: 'A vendég havi tagsága jelenleg szüneteltetve van.', NO_VALID_PASS: @@ -143,6 +158,8 @@ export type ArrivalEntitlement = /** * Vonalkódos beolvasás döntése (TICKET-39), egyetlen forrás az API-n és a mockban: + * 0. férfi vendégnél a csak nőknek szóló tagság és bérlet nem számít (`womenOnlyAccess`, TICKET-42); + * ha emiatt nem marad semmi, az elutasítás oka `WOMEN_ONLY`; * 1. aktív havi tagság → köredzés belépés; * 2. különben a leghamarabb lejáró, köredzésre érvényes időalapú bérlet → köredzés belépés; * 3. különben ha van érvényes (aktív) bérlet → kezelésválasztás, a leghamarabb lejáró elöl; @@ -151,17 +168,26 @@ export type ArrivalEntitlement = */ export function resolveArrivalEntitlement< P extends ArrivalPassInput, - M extends { status: MembershipStatus | string }, + M extends ArrivalMembershipInput, >( - input: { memberships: readonly M[]; passes: readonly P[] }, + input: { + memberships: readonly M[]; + passes: readonly P[]; + guestGender?: Gender | string | null; + }, now: Date = new Date(), ): ArrivalEntitlement { - const membership = input.memberships.find( + const blocked = (item: { womenOnly?: boolean | null }) => + womenOnlyAccess(input.guestGender, item.womenOnly) === 'DENIED'; + const memberships = input.memberships.filter((m) => !blocked(m)); + const passes = input.passes.filter((p) => !blocked(p)); + + const membership = memberships.find( (m) => m.status === MembershipStatus.ACTIVE, ); if (membership) return { kind: 'CIRCUIT_MEMBERSHIP', membership }; - const usable = input.passes + const usable = passes .filter((p) => getPassStatus(p, now) === PassStatus.ACTIVE) .sort( (a, b) => @@ -173,15 +199,24 @@ export function resolveArrivalEntitlement< if (usable.length > 0) return { kind: 'TREATMENT_SELECTION', passes: usable }; + const blockedButValid = + input.memberships.some( + (m) => blocked(m) && m.status === MembershipStatus.ACTIVE, + ) || + input.passes.some( + (p) => blocked(p) && getPassStatus(p, now) === PassStatus.ACTIVE, + ); + if (blockedButValid) return { kind: 'DENIED', reason: 'WOMEN_ONLY' }; + const today = toStudioDayKey(now); - const hasFrozenPass = input.passes.some( + const hasFrozenPass = passes.some( (p) => p.isFrozen && isPassOpen(p, p.remainingPasses) && today <= toStudioDayKey(p.expirationDate), ); if (hasFrozenPass) return { kind: 'DENIED', reason: 'PASS_FROZEN' }; - if (input.memberships.some((m) => m.status === MembershipStatus.PAUSED)) { + if (memberships.some((m) => m.status === MembershipStatus.PAUSED)) { return { kind: 'DENIED', reason: 'MEMBERSHIP_PAUSED' }; } return { kind: 'DENIED', reason: 'NO_VALID_PASS' }; From af678f4ce05630892e15886aa318c7d93356e802 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Sikl=C3=B3di?= Date: Wed, 11 Nov 2026 21:29:08 +0100 Subject: [PATCH 3/3] =?UTF-8?q?fix(api,web,shared):=20TICKET-43=20a=20vend?= =?UTF-8?q?=C3=A9gmonitor=20a=20szolg=C3=A1ltat=C3=A1shoz=20ill=C5=91en=20?= =?UTF-8?q?k=C3=B6sz=C3=B6nt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A CheckInResultPayload és a MonitorCheckInPayload (toMonitorPayload engedélylista) megkapja a szolgáltatás kategóriáját (serviceCategory) és a womenOnly jelzést. - Közös szövegtérkép: greetingFor / greetingForCheckIn (packages/shared/src/greeting.ts): köredzés „Köredzés • Érkezés” (womenOnly: „Női Köredzés • Érkezés”) — „Jó edzést kívánunk!”, alakformálás „Kezelés • Üdvözlés” — „Kellemes kezelést kívánunk!”, kozmetika „Kozmetika • Üdvözlés” — „Kellemes kikapcsolódást kívánunk!”, ismeretlen: „Üdvözlés” — „Kellemes időtöltést kívánunk!”. Ezt használja a monitor, a recepciós toast, a LastCheckInCard és a DeductTreatmentModal monitor-előnézete. - API és mock: tagságnál a Membership.serviceId szolgáltatása, időalapú bérletnél a bérlet valódi szolgáltatása és neve; a kezeléslevonás a kezelés kategóriáját adja. A dupla beolvasás és az idempotens visszajátszás a korábbi belépés módját és kategóriáját adja. - Tesztek: greeting unit, toMonitorPayload (új mezők átmennek, érzékeny nem), check-ins (tagság, bérlet, dupla beolvasás, visszajátszás), kezeléslevonás, monitor E2E (kozmetika, női köredzés). Újdonságok bejegyzés (FIX). Co-Authored-By: Claude Opus 5.5 --- .../check-ins/check-ins.service.spec.ts | 184 ++++++++++++++++++ .../modules/check-ins/check-ins.service.ts | 92 +++++++-- apps/api/src/modules/monitor/greeting.spec.ts | 108 ++++++++++ .../modules/monitor/monitor.gateway.spec.ts | 50 +++++ .../modules/passes/passes-women-only.spec.ts | 24 +++ apps/api/src/modules/passes/passes.service.ts | 5 + .../check-in/ui/MonitorDisplayCard.tsx | 15 +- .../ui/DeductTreatmentModal.tsx | 3 +- .../src/pages/reception/model/releaseNotes.ts | 13 ++ .../reception/ui/desk/LastCheckInCard.tsx | 13 +- apps/web/src/shared/api/mock-engine.ts | 35 +++- apps/web/src/shared/lib/notify/notify.ts | 7 +- docs/ARCHITECTURE.md | 1 + e2e/monitor.spec.ts | 41 ++++ packages/shared/src/greeting.ts | 86 ++++++++ packages/shared/src/index.ts | 1 + packages/shared/src/monitor-payload.ts | 18 +- packages/shared/src/socket-events.ts | 8 + 18 files changed, 677 insertions(+), 27 deletions(-) create mode 100644 apps/api/src/modules/monitor/greeting.spec.ts create mode 100644 packages/shared/src/greeting.ts diff --git a/apps/api/src/modules/check-ins/check-ins.service.spec.ts b/apps/api/src/modules/check-ins/check-ins.service.spec.ts index e2da11c..fa96f8d 100644 --- a/apps/api/src/modules/check-ins/check-ins.service.spec.ts +++ b/apps/api/src/modules/check-ins/check-ins.service.spec.ts @@ -928,4 +928,188 @@ describe('CheckInsService — Kritikus Beléptetési és Köredzés vs. Kezelés expect(res.warnings).toContain('GENDER_MISSING'); }); }); + describe('TICKET-43 — a szolgáltatáshoz illő üdvözlés', () => { + const circuitService = { + id: 'srv-circuit', + name: 'Női Köredzés (Havi Tagság)', + category: 'CIRCUIT', + womenOnly: true, + }; + const facialService = { + id: 'srv-facial', + name: 'Hialuronos Arckezelés', + category: 'COSMETIC', + womenOnly: false, + }; + const membership = { + id: 'mem-1', + status: 'ACTIVE', + monthlyFeeHuf: 22000, + serviceId: 'srv-circuit', + service: circuitService, + dues: [], + }; + const circuitPass = { + id: 'pass-circuit', + passTemplateId: 'tpl-unlimited', + serviceId: 'srv-circuit', + service: circuitService, + type: 'TIME_BASED', + totalPasses: null, + remainingPasses: null, + startDate: pastDate(5), + expirationDate: futureDate(25), + isActive: true, + isFrozen: false, + frozenAt: null, + cancelledAt: null, + passTemplate: { name: 'Havi Korlátlan Köredzés Bérlet' }, + }; + const facialPass = { + ...circuitPass, + id: 'pass-facial', + passTemplateId: 'tpl-facial-5', + serviceId: 'srv-facial', + service: facialService, + type: 'OCCASION_BASED', + totalPasses: 5, + remainingPasses: 3, + passTemplate: { name: '5 Alkalmas Prémium Kozmetikai Bérlet' }, + }; + const guestWith = (memberships: unknown[], passes: unknown[] = []) => ({ + id: 'guest-anna', + firstName: 'Anna', + lastName: 'Kovács', + barcode: '100001', + gender: 'FEMALE', + isActive: true, + memberships, + passes, + }); + + it('tagsággal: a tagság szolgáltatásának kategóriája, neve és womenOnly jelzése megy a monitorra', async () => { + txMock.guest.findUnique.mockResolvedValue(guestWith([membership])); + + const res = await service.processBarcodeCheckIn({ barcode: '100001' }, 'staff-1'); + + expect(res).toMatchObject({ + status: CheckInStatus.SUCCESS, + mode: 'CIRCUIT_GREETING', + serviceName: 'Női Köredzés (Havi Tagság)', + serviceCategory: 'CIRCUIT', + womenOnly: true, + }); + expect(monitorGatewayMock.emitCheckInResult).toHaveBeenCalledWith( + expect.objectContaining({ serviceCategory: 'CIRCUIT', womenOnly: true }), + 'default-studio', + ); + }); + + it('időalapú bérlettel: a bérlet valódi szolgáltatásneve, nem égetett „Köredzés”', async () => { + txMock.guest.findUnique.mockResolvedValue(guestWith([], [circuitPass])); + + const res = await service.processBarcodeCheckIn({ barcode: '100001' }, 'staff-1'); + + expect(res).toMatchObject({ + status: CheckInStatus.SUCCESS, + mode: 'CIRCUIT_GREETING', + serviceName: 'Női Köredzés (Havi Tagság)', + serviceCategory: 'CIRCUIT', + womenOnly: true, + }); + }); + + it('szolgáltatás nélküli időalapú bérlettel: köredzés, „Női” jelzés nélkül', async () => { + txMock.guest.findUnique.mockResolvedValue( + guestWith([], [{ ...circuitPass, serviceId: null, service: null }]), + ); + + const res = await service.processBarcodeCheckIn({ barcode: '100001' }, 'staff-1'); + + expect(res).toMatchObject({ + serviceName: 'Köredzés', + serviceCategory: 'CIRCUIT', + womenOnly: false, + }); + }); + + it('dupla beolvasás kozmetikai kezelés után: a korábbi belépés kategóriája és szövege, nem CIRCUIT_GREETING', async () => { + txMock.guest.findUnique.mockResolvedValue(guestWith([membership], [facialPass])); + txMock.loggedCheckIn.findFirst.mockResolvedValue({ + id: 'recent-treatment', + guestId: 'guest-anna', + visitType: 'TREATMENT', + status: 'SUCCESS', + createdAt: new Date(Date.now() - 3 * 1000), + guestPass: facialPass, + passUsage: { service: facialService }, + }); + + const res = await service.processBarcodeCheckIn({ barcode: '100001' }, 'staff-1'); + + expect(res).toMatchObject({ + status: CheckInStatus.DUPLICATE_SCAN, + mode: 'TREATMENT_GREETING', + serviceName: 'Hialuronos Arckezelés', + serviceCategory: 'COSMETIC', + womenOnly: false, + remainingPasses: 3, + }); + expect(txMock.loggedCheckIn.create).not.toHaveBeenCalled(); + expect(monitorGatewayMock.emitCheckInResult).toHaveBeenCalledWith( + expect.objectContaining({ mode: 'TREATMENT_GREETING', serviceCategory: 'COSMETIC' }), + 'default-studio', + ); + }); + + it('dupla beolvasás tagsággal történt köredzés után: köredzés a tagság szolgáltatásával', async () => { + txMock.guest.findUnique.mockResolvedValue(guestWith([membership], [facialPass])); + txMock.loggedCheckIn.findFirst.mockResolvedValue({ + id: 'recent-circuit', + guestId: 'guest-anna', + visitType: 'CIRCUIT_TRAINING', + status: 'SUCCESS', + createdAt: new Date(Date.now() - 3 * 1000), + guestPass: null, + passUsage: null, + }); + + const res = await service.processBarcodeCheckIn({ barcode: '100001' }, 'staff-1'); + + expect(res).toMatchObject({ + status: CheckInStatus.DUPLICATE_SCAN, + mode: 'CIRCUIT_GREETING', + serviceName: 'Női Köredzés (Havi Tagság)', + serviceCategory: 'CIRCUIT', + womenOnly: true, + }); + }); + + it('idempotens visszajátszás: a korábbi belépés kategóriája', async () => { + txMock.loggedCheckIn.findUnique.mockResolvedValue({ + id: 'offline-circuit', + visitType: 'CIRCUIT_TRAINING', + status: 'SUCCESS', + guestId: 'guest-anna', + createdAt: new Date(), + guest: { ...guestWith([membership]) }, + guestPass: circuitPass, + passUsage: null, + }); + + const res = await service.processBarcodeCheckIn( + { barcode: '100001', idempotencyKey: 'offline-key-43' }, + 'staff-1', + ); + + expect(res).toMatchObject({ + checkInId: 'offline-circuit', + mode: 'CIRCUIT_GREETING', + serviceName: 'Női Köredzés (Havi Tagság)', + serviceCategory: 'CIRCUIT', + womenOnly: true, + }); + expect(txMock.guest.findUnique).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/api/src/modules/check-ins/check-ins.service.ts b/apps/api/src/modules/check-ins/check-ins.service.ts index 7a8fbca..4e5171b 100644 --- a/apps/api/src/modules/check-ins/check-ins.service.ts +++ b/apps/api/src/modules/check-ins/check-ins.service.ts @@ -9,11 +9,13 @@ import { DEFAULT_STUDIO_ID, DUPLICATE_SCAN_COOLDOWN_SECONDS, EXPIRING_SOON_THRESHOLD_DAYS, + greetingFields, isBirthdayToday, LoggedCheckInDto, OfflineSyncResponseDto, PassType, resolveArrivalEntitlement, + ServiceCategory, VisitType, womenOnlyAccess, } from '@reform/shared'; @@ -27,6 +29,46 @@ import { } from '../passes/pass-ledger'; import { ScanCheckInDto, SyncOfflineScansDto } from './check-ins.dto'; +interface GreetingServiceRow { + name: string; + category: string; + womenOnly: boolean; +} + +/** + * Egy korábbi belépés üdvözlése (dupla beolvasás, idempotens visszajátszás, TICKET-43): kezelésnél + * a levonás szolgáltatása, köredzésnél a bérlet szolgáltatása, bérlet nélkül a tagságé. + */ +function priorCheckInGreeting( + checkIn: { + visitType: string; + guestPass?: { + service?: GreetingServiceRow | null; + passTemplate?: { service?: GreetingServiceRow | null } | null; + } | null; + passUsage?: { service?: GreetingServiceRow | null } | null; + }, + memberships: { status: string; service?: GreetingServiceRow | null }[] = [], +) { + const isCircuit = checkIn.visitType === 'CIRCUIT_TRAINING'; + const membershipService = checkIn.guestPass + ? null + : ((memberships.find((m) => m.status === 'ACTIVE') ?? memberships[0]) + ?.service ?? null); + const service = + checkIn.passUsage?.service ?? + checkIn.guestPass?.service ?? + checkIn.guestPass?.passTemplate?.service ?? + (isCircuit ? membershipService : null); + return { + mode: isCircuit + ? ('CIRCUIT_GREETING' as const) + : ('TREATMENT_GREETING' as const), + serviceName: service?.name ?? (isCircuit ? 'Köredzés' : undefined), + ...greetingFields(service, isCircuit ? ServiceCategory.CIRCUIT : null), + }; +} + @Injectable() export class CheckInsService { constructor( @@ -37,7 +79,8 @@ export class CheckInsService { /** * 2.3 Vonalkódos beléptetés (TICKET-39). A döntés a közös `resolveArrivalEntitlement`: * - aktív havi tagság vagy köredzésre érvényes időalapú bérlet → köredzéses érkezés - * (visits + logged_check_ins) ALKALOMLEVONÁS NÉLKÜL, a monitorra 'CIRCUIT_GREETING' megy; + * (visits + logged_check_ins) ALKALOMLEVONÁS NÉLKÜL, a monitorra 'CIRCUIT_GREETING' megy + * a szolgáltatás kategóriájával és `womenOnly` jelzésével (TICKET-43); * - csak kezelésre szóló bérlet → TREATMENT_SELECTION_REQUIRED: semmi nem rögzül és a monitor * sem kap eseményt; a levonás a kezelésválasztóból (`deductTreatmentUsage`) történik; * - semmi érvényes → elutasítás (DENIED napló). @@ -56,18 +99,32 @@ export class CheckInsService { const existingByKey = await tx.loggedCheckIn.findUnique({ where: { idempotencyKey: dto.idempotencyKey }, include: { - guest: true, - guestPass: { include: { passTemplate: true } }, + guest: { + include: { + memberships: { + include: { service: true }, + orderBy: { createdAt: 'desc' }, + }, + }, + }, + guestPass: { + include: { + passTemplate: { include: { service: true } }, + service: true, + }, + }, + passUsage: { include: { service: true } }, }, }); if (existingByKey) { + // A korábbi belépés szövege és kategóriája (TICKET-43). return { checkInId: existingByKey.id, - mode: - existingByKey.visitType === 'CIRCUIT_TRAINING' - ? ('CIRCUIT_GREETING' as const) - : ('TREATMENT_GREETING' as const), + ...priorCheckInGreeting( + existingByKey, + existingByKey.guest.memberships, + ), status: existingByKey.status as unknown as CheckInStatus, guestId: existingByKey.guestId, barcode: existingByKey.guest.barcode, @@ -131,15 +188,22 @@ export class CheckInsService { createdAt: { gte: cooldownSince }, }, include: { - guestPass: { include: { passTemplate: true } }, + guestPass: { + include: { + passTemplate: { include: { service: true } }, + service: true, + }, + }, + passUsage: { include: { service: true } }, }, orderBy: { createdAt: 'desc' }, }); if (recentCheckIn) { + // A korábbi belépés szövege és kategóriája, nem égetett köredzés (TICKET-43). return { checkInId: recentCheckIn.id, - mode: 'CIRCUIT_GREETING', + ...priorCheckInGreeting(recentCheckIn, guest.memberships), status: CheckInStatus.DUPLICATE_SCAN, guestId: guest.id, barcode: guest.barcode, @@ -245,7 +309,11 @@ export class CheckInsService { guestId: guest.id, barcode: guest.barcode, guestName: guestFullName, - serviceName: 'Köredzés', + serviceName: entitlement.membership.service?.name ?? 'Köredzés', + ...greetingFields( + entitlement.membership.service, + ServiceCategory.CIRCUIT, + ), passName: `Havi Köredzés Tagság (${entitlement.membership.monthlyFeeHuf.toLocaleString('hu-HU')} Ft/hó)`, remainingPasses: 'Korlátlan', warnings, @@ -282,7 +350,9 @@ export class CheckInsService { guestId: guest.id, barcode: guest.barcode, guestName: guestFullName, - serviceName: 'Köredzés', + // A bérlet valódi szolgáltatása (TICKET-43); szolgáltatás nélküli bérletnél köredzés. + serviceName: circuitPass.service?.name ?? 'Köredzés', + ...greetingFields(circuitPass.service, ServiceCategory.CIRCUIT), passName: circuitPass.passTemplate.name, passType: circuitPass.type as unknown as PassType, remainingPasses: 'Korlátlan', diff --git a/apps/api/src/modules/monitor/greeting.spec.ts b/apps/api/src/modules/monitor/greeting.spec.ts new file mode 100644 index 0000000..0c166e7 --- /dev/null +++ b/apps/api/src/modules/monitor/greeting.spec.ts @@ -0,0 +1,108 @@ +import { + greetingFields, + greetingFor, + greetingForCheckIn, + ServiceCategory, +} from '@reform/shared'; +import { describe, expect, it } from 'vitest'; + +describe('greetingFor (TICKET-43)', () => { + it('köredzés: „Köredzés • Érkezés” — „Jó edzést kívánunk!”', () => { + expect(greetingFor({ category: ServiceCategory.CIRCUIT })).toEqual({ + kind: 'Köredzés', + label: 'Köredzés • Érkezés', + wish: 'Jó edzést kívánunk!', + }); + }); + + it('csak nőknek szóló köredzés: „Női Köredzés • Érkezés”', () => { + expect( + greetingFor({ category: ServiceCategory.CIRCUIT, womenOnly: true }), + ).toEqual({ + kind: 'Női Köredzés', + label: 'Női Köredzés • Érkezés', + wish: 'Jó edzést kívánunk!', + }); + }); + + it('alakformálás (Hypoxi): „Kezelés • Üdvözlés” — „Kellemes kezelést kívánunk!”', () => { + expect(greetingFor({ category: ServiceCategory.BODY_SHAPING })).toEqual({ + kind: 'Kezelés', + label: 'Kezelés • Üdvözlés', + wish: 'Kellemes kezelést kívánunk!', + }); + }); + + it('kozmetika: „Kozmetika • Üdvözlés” — „Kellemes kikapcsolódást kívánunk!”, „Női” nélkül', () => { + for (const womenOnly of [false, true]) { + expect( + greetingFor({ category: ServiceCategory.COSMETIC, womenOnly }), + ).toEqual({ + kind: 'Kozmetika', + label: 'Kozmetika • Üdvözlés', + wish: 'Kellemes kikapcsolódást kívánunk!', + }); + } + }); + + it('ismeretlen kategória: semleges címke — „Kellemes időtöltést kívánunk!”', () => { + for (const category of [null, undefined, 'OTHER']) { + const greeting = greetingFor({ category }); + expect(greeting.label).toBe('Üdvözlés'); + expect(greeting.wish).toBe('Kellemes időtöltést kívánunk!'); + } + }); +}); + +describe('greetingForCheckIn (TICKET-43)', () => { + it('a kategória dönt, nem a mód: kozmetikai kezelésnél nincs „Jó edzést”', () => { + expect( + greetingForCheckIn({ + mode: 'TREATMENT_GREETING', + serviceCategory: ServiceCategory.COSMETIC, + }).wish, + ).toBe('Kellemes kikapcsolódást kívánunk!'); + expect( + greetingForCheckIn({ + mode: 'CIRCUIT_GREETING', + serviceCategory: ServiceCategory.CIRCUIT, + womenOnly: true, + }).label, + ).toBe('Női Köredzés • Érkezés'); + }); + + it('kategória nélküli (régebbi) eseménynél a mód szerinti szöveg, „Női” nélkül', () => { + expect(greetingForCheckIn({ mode: 'CIRCUIT_GREETING' }).label).toBe( + 'Köredzés • Érkezés', + ); + expect(greetingForCheckIn({ mode: 'TREATMENT_GREETING' }).wish).toBe( + 'Kellemes kezelést kívánunk!', + ); + }); + + it('kifejezetten ismeretlen (null) kategóriánál semleges szöveg', () => { + expect( + greetingForCheckIn({ mode: 'TREATMENT_GREETING', serviceCategory: null }) + .wish, + ).toBe('Kellemes időtöltést kívánunk!'); + }); +}); + +describe('greetingFields (TICKET-43)', () => { + it('a szolgáltatás kategóriáját és womenOnly jelzését adja', () => { + expect( + greetingFields({ category: 'CIRCUIT', womenOnly: true }), + ).toEqual({ serviceCategory: ServiceCategory.CIRCUIT, womenOnly: true }); + }); + + it('szolgáltatás nélkül a tartalék kategória, alapból ismeretlen', () => { + expect(greetingFields(null, ServiceCategory.CIRCUIT)).toEqual({ + serviceCategory: ServiceCategory.CIRCUIT, + womenOnly: false, + }); + expect(greetingFields(undefined)).toEqual({ + serviceCategory: null, + womenOnly: false, + }); + }); +}); diff --git a/apps/api/src/modules/monitor/monitor.gateway.spec.ts b/apps/api/src/modules/monitor/monitor.gateway.spec.ts index 9a4050e..d9e728a 100644 --- a/apps/api/src/modules/monitor/monitor.gateway.spec.ts +++ b/apps/api/src/modules/monitor/monitor.gateway.spec.ts @@ -6,6 +6,7 @@ import { CheckInStatus, MonitorDeviceRevokedPayload, MonitorStatePayload, + ServiceCategory, SOCKET_EVENTS, toMonitorPayload, } from '@reform/shared'; @@ -600,6 +601,55 @@ describe('toMonitorPayload', () => { }); }); + it('TICKET-43: a szolgáltatás kategóriája és a womenOnly jelzés átmegy, érzékeny adat nem', () => { + const payload = toMonitorPayload( + fullResult({ serviceCategory: ServiceCategory.COSMETIC, womenOnly: false }), + ); + expect(payload).toEqual({ + status: 'SUCCESS', + mode: 'TREATMENT_GREETING', + serviceCategory: ServiceCategory.COSMETIC, + displayName: 'Kovács Anna', + serviceName: 'Hypoxi', + remainingPasses: 1, + warnings: ['LOW_OCCASIONS'], + greetingSeconds: 7, + timestamp: '2026-11-08T10:00:00.000Z', + }); + + const circuit = toMonitorPayload( + fullResult({ + mode: 'CIRCUIT_GREETING', + status: CheckInStatus.DUPLICATE_SCAN, + serviceCategory: ServiceCategory.CIRCUIT, + womenOnly: true, + }), + ); + expect(circuit).toMatchObject({ + status: 'DUPLICATE', + serviceCategory: ServiceCategory.CIRCUIT, + womenOnly: true, + }); + + expectNoSensitiveData([payload, circuit]); + }); + + it('TICKET-43: ismeretlen (null) kategória átmegy, elutasításnál a kategória sem', () => { + expect( + toMonitorPayload(fullResult({ serviceCategory: null })).serviceCategory, + ).toBeNull(); + + const denied = toMonitorPayload( + fullResult({ + status: CheckInStatus.DENIED, + serviceCategory: ServiceCategory.CIRCUIT, + womenOnly: true, + }), + ); + expect(denied).not.toHaveProperty('serviceCategory'); + expect(denied).not.toHaveProperty('womenOnly'); + }); + it('a sztornózott (CANCELLED) állapot elutasításként, név nélkül jelenik meg', () => { const payload = toMonitorPayload( fullResult({ status: CheckInStatus.CANCELLED }), diff --git a/apps/api/src/modules/passes/passes-women-only.spec.ts b/apps/api/src/modules/passes/passes-women-only.spec.ts index 66a6545..2d5a42f 100644 --- a/apps/api/src/modules/passes/passes-women-only.spec.ts +++ b/apps/api/src/modules/passes/passes-women-only.spec.ts @@ -184,6 +184,30 @@ describe('PassesService — csak nőknek szóló bérlet és kezelés (TICKET-42 await expect(deduct('srv-hypoxi')).resolves.toMatchObject({ status: 'SUCCESS' }); }); + it('TICKET-43: a levonás a kezelés kategóriáját és womenOnly jelzését adja a monitornak', async () => { + txMock.guest.findFirst.mockResolvedValue(guest('FEMALE')); + txMock.guestPass.findFirst.mockResolvedValue(openPass()); + txMock.service.findFirst.mockResolvedValue({ + id: 'srv-facial', + name: 'Hialuronos Arckezelés', + category: 'COSMETIC', + womenOnly: false, + }); + + await expect(deduct('srv-facial')).resolves.toMatchObject({ + mode: 'TREATMENT_GREETING', + serviceName: 'Hialuronos Arckezelés', + serviceCategory: 'COSMETIC', + womenOnly: false, + }); + + txMock.service.findFirst.mockResolvedValue(circuitService); + await expect(deduct('srv-circuit')).resolves.toMatchObject({ + serviceCategory: 'CIRCUIT', + womenOnly: true, + }); + }); + it('nincs megadva nem: levonható, de GENDER_MISSING figyelmeztetéssel', async () => { txMock.guest.findFirst.mockResolvedValue(guest('UNSPECIFIED')); txMock.guestPass.findFirst.mockResolvedValue(openPass()); diff --git a/apps/api/src/modules/passes/passes.service.ts b/apps/api/src/modules/passes/passes.service.ts index 9855019..e54c771 100644 --- a/apps/api/src/modules/passes/passes.service.ts +++ b/apps/api/src/modules/passes/passes.service.ts @@ -10,6 +10,7 @@ import { DEFAULT_STUDIO_ID, EXPIRING_SOON_THRESHOLD_DAYS, frozenDaysBetween, + greetingFields, isBirthdayToday, getPassStatus, isPassFreezable, @@ -347,6 +348,8 @@ export class PassesService { serviceName: existingOp.service?.name ?? existingOp.guestPass.passTemplate.name, + // A korábbi levonás szolgáltatásának üdvözlése (TICKET-43). + ...greetingFields(existingOp.service), passName: existingOp.guestPass.passTemplate.name, passType: existingOp.guestPass.type as unknown as PassType, remainingPasses: @@ -475,6 +478,8 @@ export class PassesService { barcode: guest.barcode, guestName: `${guest.lastName} ${guest.firstName}`, serviceName, + // A monitor a kezelés kategóriájához illő szöveggel köszön (TICKET-43). + ...greetingFields(service), passName: pass.passTemplate.name, passType: pass.type as unknown as PassType, remainingPasses: newRemaining, diff --git a/apps/web/src/entities/check-in/ui/MonitorDisplayCard.tsx b/apps/web/src/entities/check-in/ui/MonitorDisplayCard.tsx index de1dfab..1dbd46f 100644 --- a/apps/web/src/entities/check-in/ui/MonitorDisplayCard.tsx +++ b/apps/web/src/entities/check-in/ui/MonitorDisplayCard.tsx @@ -1,4 +1,5 @@ import { + greetingForCheckIn, MonitorCheckInPayload, MonitorCheckInRevokedPayload, } from '@reform/shared'; @@ -128,6 +129,8 @@ export const MonitorDisplayCard: React.FC = ({ const isSuccess = activeResult.status === 'SUCCESS'; const isDuplicate = activeResult.status === 'DUPLICATE'; const isTreatment = activeResult.mode === 'TREATMENT_GREETING'; + // Címke és jókívánság a szolgáltatás kategóriájából (TICKET-43), nem a módból. + const greeting = greetingForCheckIn(activeResult); const hasSafeWarnings = activeResult.warnings.length > 0; // Csak sikeres belépésnél (toMonitorPayload is csak ekkor adja át). const isBirthday = isSuccess && activeResult.isBirthday === true; @@ -150,9 +153,7 @@ export const MonitorDisplayCard: React.FC = ({
    {isSuccess - ? isTreatment - ? 'Kezelés • Üdvözlés' - : 'Női Köredzés • Érkezés' + ? greeting.label : isDuplicate ? 'Érkezés Már Rögzítve' : 'Recepciós Egyeztetés'} @@ -212,7 +213,7 @@ export const MonitorDisplayCard: React.FC = ({ {isSuccess || isDuplicate ? ( !isTreatment ? ( - /* 5.3 Köredzéses érkezés után: „Üdvözlünk, [Keresztnév]! Jó edzést kívánunk!” */ + /* 5.3 Köredzéses érkezés után: „Üdvözlünk, [Keresztnév]! [Jókívánság]” */
    = ({ compact ? 'text-xl' : 'text-4xl' }`} > - Jó edzést kívánunk! + {greeting.wish}

    ) : ( - /* 5.3 Alkalmas kezelés levonása után: „Üdvözlünk, [Név]! [Kezelés neve]. Hátralévő alkalmak: [X]. Kellemes kezelést kívánunk!” */ + /* 5.3 Alkalmas kezelés levonása után: „Üdvözlünk, [Név]! [Kezelés neve]. Hátralévő alkalmak: [X]. [Jókívánság]” */
    = ({ compact ? 'text-base' : 'text-2xl' }`} > - Kellemes kezelést kívánunk! + {greeting.wish}

    ) diff --git a/apps/web/src/features/deduct-treatment/ui/DeductTreatmentModal.tsx b/apps/web/src/features/deduct-treatment/ui/DeductTreatmentModal.tsx index c96a2fc..4bba2ae 100644 --- a/apps/web/src/features/deduct-treatment/ui/DeductTreatmentModal.tsx +++ b/apps/web/src/features/deduct-treatment/ui/DeductTreatmentModal.tsx @@ -1,4 +1,5 @@ import { + greetingFor, GuestDto, passCoversService, PassStatus, @@ -416,7 +417,7 @@ export const DeductTreatmentModal: React.FC = ({ „Üdvözlünk, {selectedGuest.lastName} {selectedGuest.firstName} ! {selectedService.name}. Hátralévő alkalmak:{' '} - {remainingAfterDeduction}. Kellemes kezelést kívánunk!” + {remainingAfterDeduction}. {greetingFor(selectedService).wish}” )} diff --git a/apps/web/src/pages/reception/model/releaseNotes.ts b/apps/web/src/pages/reception/model/releaseNotes.ts index 61594ed..61642be 100644 --- a/apps/web/src/pages/reception/model/releaseNotes.ts +++ b/apps/web/src/pages/reception/model/releaseNotes.ts @@ -96,6 +96,19 @@ export const getJiraIssueUrl = (jiraKey: string): string => `${JIRA_BASE_URL}/browse/${encodeURIComponent(jiraKey)}`; export const RELEASE_NOTES: ReleaseNote[] = [ + { + id: 'v0.3-fix-monitor-service-greeting', + version: '0.3', + date: '2026-11-11', + kind: 'FIX', + title: 'Szolgáltatáshoz illő üdvözlés', + summary: 'A vendégmonitor a szolgáltatáshoz illően köszönti a vendéget.', + details: [ + 'Köredzésnél „Jó edzést kívánunk!” (csak nőknek szóló köredzésnél „Női Köredzés” címkével), alakformáló kezelésnél „Kellemes kezelést kívánunk!”, kozmetikánál „Kellemes kikapcsolódást kívánunk!”.', + 'Ugyanez a szöveg jelenik meg a recepciós visszajelzésben, az „Utoljára azonosított vendég” kártyán és a kezelésválasztó monitor-előnézetében; dupla beolvasásnál a korábbi belépés szövege marad.', + ], + jiraKey: 'TICKET-43', + }, { id: 'v0.3-feature-guest-gender-women-only', version: '0.3', diff --git a/apps/web/src/pages/reception/ui/desk/LastCheckInCard.tsx b/apps/web/src/pages/reception/ui/desk/LastCheckInCard.tsx index 9d05f86..e3b837c 100644 --- a/apps/web/src/pages/reception/ui/desk/LastCheckInCard.tsx +++ b/apps/web/src/pages/reception/ui/desk/LastCheckInCard.tsx @@ -1,4 +1,9 @@ -import { CheckInResultPayload, CheckInStatus, GuestDto } from '@reform/shared'; +import { + CheckInResultPayload, + CheckInStatus, + greetingForCheckIn, + GuestDto, +} from '@reform/shared'; import { AlertTriangle, RotateCcw, Ticket, Users } from 'lucide-react'; import React from 'react'; import { @@ -45,6 +50,8 @@ export const LastCheckInCard: React.FC = ({ (lastCheckIn?.status === CheckInStatus.SUCCESS || lastCheckIn?.status === CheckInStatus.DUPLICATE_SCAN) && lastCheckIn.mode !== 'CIRCUIT_GREETING'; + // A szolgáltatás neve a közös üdvözlés-térképből (TICKET-43), mint a monitoron és a toastban. + const greeting = lastCheckIn && greetingForCheckIn(lastCheckIn); return ( @@ -85,8 +92,8 @@ export const LastCheckInCard: React.FC = ({ {statusBadge.label} {lastCheckIn.mode === 'CIRCUIT_GREETING' - ? 'Köredzés (0 levonás)' - : `Kezelés: ${lastCheckIn.serviceName ?? 'Alkalom levonás'}`} + ? `${greeting?.kind} (0 levonás)` + : `${greeting?.kind}: ${lastCheckIn.serviceName ?? 'Alkalom levonás'}`}

    diff --git a/apps/web/src/shared/api/mock-engine.ts b/apps/web/src/shared/api/mock-engine.ts index df0c615..5f5cf83 100644 --- a/apps/web/src/shared/api/mock-engine.ts +++ b/apps/web/src/shared/api/mock-engine.ts @@ -30,6 +30,7 @@ import { getOverdueBucket, getOverdueDays, getStudioDate, + greetingFields, inferGenderFromFirstName, Gender, GuestDocumentDto, @@ -1788,6 +1789,8 @@ class MockStudioEngine { barcode: guest.barcode, guestName: `${guest.lastName} ${guest.firstName}`, serviceName, + // A monitor a kezelés kategóriájához illő szöveggel köszön (TICKET-43, mint az API-n). + ...greetingFields(service), passName: pass.passTemplateName, passType: pass.type, remainingPasses: newRemaining, @@ -1993,13 +1996,36 @@ class MockStudioEngine { const activePass = (guest.passes ?? []).find( (p) => p.id === recent.guestPassId, ); + // A korábbi belépés szövege és kategóriája, nem égetett köredzés (TICKET-43, mint az + // API-n): kezelésnél a levonás szolgáltatása, köredzésnél a bérleté, bérlet nélkül a tagságé. + const isCircuit = recent.visitType !== VisitType.TREATMENT; + const usage = (guest.passes ?? []) + .flatMap((p) => p.usageLogs ?? []) + .find((u) => u.checkInId === recent.id); + const memberships = guest.memberships ?? []; + const membership = + memberships.find((m) => m.status === MembershipStatus.ACTIVE) ?? + memberships[0]; + const priorServiceId = + usage?.serviceId ?? + activePass?.serviceId ?? + (isCircuit && !activePass ? membership?.serviceId : null); + const priorService = priorServiceId + ? state.services.find((s) => s.id === priorServiceId) + : undefined; const dupPayload: CheckInResultPayload = { checkInId: recent.id, - mode: 'CIRCUIT_GREETING', + mode: isCircuit ? 'CIRCUIT_GREETING' : 'TREATMENT_GREETING', status: CheckInStatus.DUPLICATE_SCAN, guestId: guest.id, barcode: guest.barcode, guestName: guestFullName, + serviceName: + priorService?.name ?? (isCircuit ? 'Köredzés' : undefined), + ...greetingFields( + priorService, + isCircuit ? ServiceCategory.CIRCUIT : null, + ), passName: activePass?.passTemplateName ?? recent.passName ?? undefined, passType: activePass?.type, remainingPasses: @@ -2100,6 +2126,10 @@ class MockStudioEngine { : undefined; const circuitPass = entitlement.kind === 'CIRCUIT_PASS' ? entitlement.pass : undefined; + // A tagság vagy a bérlet valódi szolgáltatása (TICKET-43); nélküle köredzés. + const arrivalService = serviceOf( + membership ? membership.serviceId : circuitPass?.serviceId, + ); if ( circuitPass && Math.ceil( @@ -2159,7 +2189,8 @@ class MockStudioEngine { guestId: guest.id, barcode: guest.barcode, guestName: guestFullName, - serviceName: 'Köredzés', + serviceName: arrivalService?.name ?? 'Köredzés', + ...greetingFields(arrivalService, ServiceCategory.CIRCUIT), passName: checkInLog.passName ?? 'Köredzés', passType: circuitPass?.type, remainingPasses: 'Korlátlan', diff --git a/apps/web/src/shared/lib/notify/notify.ts b/apps/web/src/shared/lib/notify/notify.ts index 609f13d..2d26cc2 100644 --- a/apps/web/src/shared/lib/notify/notify.ts +++ b/apps/web/src/shared/lib/notify/notify.ts @@ -2,6 +2,7 @@ import { CheckInResultPayload, CheckInStatus, DUPLICATE_SCAN_COOLDOWN_SECONDS, + greetingForCheckIn, } from '@reform/shared'; import { toast } from 'sonner'; import { soundFeedback } from '../sound'; @@ -72,6 +73,8 @@ export const notify = { } const isCircuit = payload.mode === 'CIRCUIT_GREETING'; + // A szolgáltatás neve a közös üdvözlés-térképből (TICKET-43), pl. „Női Köredzés”, „Kozmetika”. + const { kind } = greetingForCheckIn(payload); const remainingText = isCircuit ? 'Köredzés érkezés (0 levonás)' : payload.remainingPasses === 'Korlátlan' @@ -132,8 +135,8 @@ export const notify = { } else { toast.success( isCircuit - ? `Köredzés érkezés: ${payload.guestName}` - : `Kezelés levonva: ${payload.guestName}`, + ? `${kind} érkezés: ${payload.guestName}` + : `${kind} levonva: ${payload.guestName}`, { description, duration: 5500, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4d13d04..fa928bb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -54,6 +54,7 @@ A szabályokat a [Steiger](https://github.com/feature-sliced/steiger) linter ell - **Másodlagos Monitor (Azonos PC):** A böngésző natív `BroadcastChannel('reform_monitor_channel')` csatornáján `< 1 ms` késleltetéssel kapja meg a beléptetési és sztornó eseményeket. - **Önálló Eszköz (Tablet / iPad / Smart TV):** A recepción generált 6 jegyű párosító kóddal (`POST /api/auth/monitor/pair-init` -> `POST /api/auth/monitor/pair-verify`) az eszköz egy dedikált `deviceToken`-t kap, amellyel autentikálódik a `/monitor` Socket.io névtéren. - **Adatszűrés (TICKET-17):** A monitorra csak a `toMonitorPayload` (`@reform/shared`) által szűkített `MonitorCheckInPayload` / `MonitorCheckInRevokedPayload` kerül: tartozás, vonalkód, azonosító és elutasítási ok nélkül, elutasításnál név nélkül. A gateway két szobát használ: `studio:` (minden kliens; a `monitor:checkin-result` / `monitor:checkin-revoked` szűkítve, a staff szoba kivételével) és `studio::staff` (csak munkatársi token, teljes `checkin:result` / `checkin:revoked`). A munkatársi tokennel megnyitott `/monitor` `displayOnly` jelzővel csatlakozik, így a staff szobába nem kerül be, és a recepciós scanner-csatornára sem iratkozik fel. A helyi `broadcastToLocalMonitors` ugyanezzel a függvénnyel szűr. +- **Szolgáltatáshoz illő üdvözlés (TICKET-43):** A `CheckInResultPayload` és a `MonitorCheckInPayload` a szolgáltatás kategóriáját (`serviceCategory`) és `womenOnly` jelzését is viszi (nem érzékeny). A címke és a jókívánság egyetlen forrása a `greetingFor` / `greetingForCheckIn` (`packages/shared/src/greeting.ts`): ezt használja a monitor, a recepciós toast, az „Utoljára azonosított vendég” kártya és a kezelésválasztó monitor-előnézete. Dupla beolvasásnál és idempotens visszajátszásnál a korábbi belépés szolgáltatása dönt. - **Visszavonás és elutasítás (TICKET-31):** Törlés vagy új párosító kód után a `MonitorGateway.disconnectDevice` az eszköz minden socketjének közvetlenül elküldi a `monitor:device-revoked` eseményt, bontja őket, és a nyilvántartásból azonnal törli (a recepció csak az új `monitor:state` kijelzőszámot kapja). A visszavonás alatt hitelesülő, régi tokenes kapcsolat nem regisztrálhat (visszavonási sorszám). Elutasított vagy hiányzó tokennél a szerver a bontás előtt `monitor:auth-rejected` eseményt küld (`MonitorAuthRejectedPayload`: katalóguskód és -üzenet). - **Kliensoldali kapcsolatállapot (TICKET-32):** A `useMonitorChannel` `connectionStatus`-a (`idle` / `connecting` / `live` / `offline`) csak a szerver `monitor:state` üzenete után `live` (a socket `connect` eseménye még nem jelenti, hogy a gateway beléptette a szobába); bontásnál, `connect_error`-nál és elutasításnál `offline`, és a kijelzőszám 0-ra áll. `monitor:auth-rejected` után `COMMON.INTERNAL` kódnál a hook 5 mp múlva kézzel újracsatlakozik (legfeljebb 5-ször, élő kapcsolat után újra), más kódnál nem. Eszköztokennel futó monitoron az `AUTH.DEVICE_TOKEN_INVALID` és a saját `monitor:device-revoked` törli a párosítást (kódbekérő képernyő); munkatársi munkamenetnél a párosítás marad. diff --git a/e2e/monitor.spec.ts b/e2e/monitor.spec.ts index bade6a0..aacfbaa 100644 --- a/e2e/monitor.spec.ts +++ b/e2e/monitor.spec.ts @@ -5,6 +5,7 @@ import { expect, expectMonitorIdle, loginToReception, + quickSearchRow, scanBarcode, test, toasts, @@ -65,6 +66,46 @@ test.describe('Regresszió: TICKET-2', () => { ); }); +test.describe('Regresszió: TICKET-43', () => { + test( + 'TICKET-43: köredzésre belépő vendéget a csak nőknek szóló köredzéshez illően köszönti', + async ({ page }) => { + const monitor = await openReceptionWithMonitor(page); + // Kovács Anna (#100001): havi tagság a „Női Köredzés (Havi Tagság)” szolgáltatásra. + await scanBarcode(page, '100001'); + + await expect(monitor.getByText('Női Köredzés • Érkezés', { exact: true })).toBeVisible({ timeout: 1_000 }); + await expect(monitor.getByText('Jó edzést kívánunk!', { exact: true })).toBeVisible(); + await expect(monitor.getByRole('heading', { name: 'Kovács Anna!' })).toBeVisible(); + }, + ); + + test( + 'TICKET-43: prémium kozmetikai bérletnél a monitor nem ír „Jó edzést”-et', + async ({ page }) => { + const monitor = await openReceptionWithMonitor(page); + // Kovács Anna (#100001): 5 alkalmas prémium kozmetikai bérlet (Hialuronos arckezelés). + await quickSearchRow(page, '100001').getByRole('button', { name: 'Kezelés -1', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: 'Alkalmas Kezelés Kiválasztása & Bérlet Levonás' }); + await expect(dialog).toBeVisible(); + await dialog.getByRole('button', { name: /^Hialuronos Arckezelés/ }).click(); + await expect(dialog.getByRole('button', { name: /^5 Alkalmas Prémium Kozmetikai Bérlet/ })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + // A monitor-előnézet is a közös szövegtérképet használja. + await expect(dialog).toContainText('Kellemes kikapcsolódást kívánunk!'); + await dialog.getByRole('button', { name: '1 Alkalom Levonása & Üdvözlés' }).click(); + + await expect(monitor.getByText('Kozmetika • Üdvözlés', { exact: true })).toBeVisible({ timeout: 1_000 }); + await expect(monitor.getByText('Kellemes kikapcsolódást kívánunk!', { exact: true })).toBeVisible(); + await expect(monitor.getByRole('heading', { name: 'Kovács Anna!' })).toBeVisible(); + await expect(monitor.getByText('Jó edzést kívánunk!', { exact: true })).toHaveCount(0); + await expect(monitor.getByText(/Női Köredzés/)).toHaveCount(0); + }, + ); +}); + test.describe('Vendégmonitor adatszűrés: TICKET-17', () => { test('TICKET-17: elutasításnál a /demo monitor név és ok nélkül a recepcióhoz küld', async ({ page }) => { await page.goto('/demo'); diff --git a/packages/shared/src/greeting.ts b/packages/shared/src/greeting.ts new file mode 100644 index 0000000..2f63d13 --- /dev/null +++ b/packages/shared/src/greeting.ts @@ -0,0 +1,86 @@ +import { MonitorEventMode, ServiceCategory } from './enums'; + +/** A szolgáltatás jellege, ami az üdvözlés szövegét meghatározza (TICKET-43). Nem érzékeny adat. */ +export interface GreetingService { + category?: ServiceCategory | string | null; + womenOnly?: boolean | null; +} + +export interface ServiceGreeting { + /** A szolgáltatás rövid neve a recepción („Köredzés”, „Női Köredzés”, „Kezelés”, „Kozmetika”). */ + kind: string; + /** Címke a vendégmonitor fejlécén („Kozmetika • Üdvözlés”). */ + label: string; + /** Jókívánság a vendégnek („Kellemes kikapcsolódást kívánunk!”). */ + wish: string; +} + +/** + * Közös szövegtérkép a vendégmonitornak, a recepciós toastnak és a „Utoljára azonosított vendég” + * kártyának. A „Női” csak a csak nőknek szóló köredzésnél jelenik meg. + */ +export function greetingFor({ category, womenOnly }: GreetingService): ServiceGreeting { + switch (category) { + case ServiceCategory.CIRCUIT: { + const kind = womenOnly ? 'Női Köredzés' : 'Köredzés'; + return { kind, label: `${kind} • Érkezés`, wish: 'Jó edzést kívánunk!' }; + } + case ServiceCategory.BODY_SHAPING: + return { + kind: 'Kezelés', + label: 'Kezelés • Üdvözlés', + wish: 'Kellemes kezelést kívánunk!', + }; + case ServiceCategory.COSMETIC: + return { + kind: 'Kozmetika', + label: 'Kozmetika • Üdvözlés', + wish: 'Kellemes kikapcsolódást kívánunk!', + }; + default: + return { + kind: 'Alkalom', + label: 'Üdvözlés', + wish: 'Kellemes időtöltést kívánunk!', + }; + } +} + +/** + * Üdvözlés egy beléptetési eseményhez (recepciós vagy monitor payload). Ha a payloadban nincs + * kategória (`undefined`, pl. régebbi API-tól érkező esemény), a `mode` dönt a korábbi működés + * szerint; a kifejezetten ismeretlen (`null`) kategória semleges szöveget ad. + */ +export function greetingForCheckIn(payload: { + mode?: MonitorEventMode; + serviceCategory?: ServiceCategory | null; + womenOnly?: boolean; +}): ServiceGreeting { + const category = + payload.serviceCategory !== undefined + ? payload.serviceCategory + : payload.mode === 'CIRCUIT_GREETING' + ? ServiceCategory.CIRCUIT + : payload.mode === 'TREATMENT_GREETING' + ? ServiceCategory.BODY_SHAPING + : null; + return greetingFor({ category, womenOnly: payload.womenOnly }); +} + +/** + * A payloadba kerülő üdvözlési mezők egy szolgáltatásból. Szolgáltatás nélkül a `fallbackCategory` + * (pl. köredzéses érkezésnél CIRCUIT), egyébként `null` — ismeretlen. + */ +export function greetingFields( + service: GreetingService | null | undefined, + fallbackCategory: ServiceCategory | null = null, +): { serviceCategory: ServiceCategory | null; womenOnly: boolean } { + const category = service?.category; + return { + serviceCategory: + category && (Object.values(ServiceCategory) as string[]).includes(category) + ? (category as ServiceCategory) + : fallbackCategory, + womenOnly: !!service?.womenOnly, + }; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9fd1d4b..3358fa5 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -7,3 +7,4 @@ export * from './membership-dues'; export * from './birthday'; export * from './pass-rules'; export * from './gender'; +export * from './greeting'; diff --git a/packages/shared/src/monitor-payload.ts b/packages/shared/src/monitor-payload.ts index 05d53a1..d750156 100644 --- a/packages/shared/src/monitor-payload.ts +++ b/packages/shared/src/monitor-payload.ts @@ -1,4 +1,9 @@ -import { CheckInStatus, CheckInWarningCode, MonitorEventMode } from './enums'; +import { + CheckInStatus, + CheckInWarningCode, + MonitorEventMode, + ServiceCategory, +} from './enums'; import type { CheckInResultPayload, CheckInRevokedPayload } from './socket-events'; export type MonitorCheckInStatus = 'SUCCESS' | 'DUPLICATE' | 'DENIED'; @@ -23,6 +28,13 @@ export type MonitorSafeWarning = (typeof MONITOR_SAFE_WARNINGS)[number]; export interface MonitorCheckInPayload { status: MonitorCheckInStatus; mode?: MonitorEventMode; + /** + * Csak üdvözlésnél (SUCCESS / DUPLICATE): a szolgáltatás kategóriája, ebből jön a címke és a + * jókívánság (`greetingForCheckIn`); `null`, ha ismeretlen. + */ + serviceCategory?: ServiceCategory | null; + /** Csak üdvözlésnél, és csak ha igaz: csak nőknek szóló szolgáltatás („Női Köredzés”). */ + womenOnly?: boolean; /** Csak üdvözlésnél (SUCCESS / DUPLICATE); elutasításnál hiányzik. */ displayName?: string; /** Csak kezelés-üdvözlésnél (TREATMENT_GREETING). */ @@ -70,6 +82,10 @@ export function toMonitorPayload( if (status === 'DENIED') return base; base.displayName = result.guestName; + if (result.serviceCategory !== undefined) { + base.serviceCategory = result.serviceCategory; + } + if (result.womenOnly) base.womenOnly = true; if (status === 'SUCCESS' && result.isBirthday) base.isBirthday = true; if (result.mode === 'TREATMENT_GREETING') { base.serviceName = result.serviceName ?? result.passName; diff --git a/packages/shared/src/socket-events.ts b/packages/shared/src/socket-events.ts index e1392a8..26880cf 100644 --- a/packages/shared/src/socket-events.ts +++ b/packages/shared/src/socket-events.ts @@ -3,6 +3,7 @@ import { CheckInWarningCode, MonitorEventMode, PassType, + ServiceCategory, } from './enums'; export const SOCKET_NAMESPACE = '/monitor'; @@ -59,6 +60,13 @@ export interface CheckInResultPayload { barcode: string; guestName: string; serviceName?: string; + /** + * A belépés szolgáltatásának kategóriája (az üdvözlés szövegéhez, `greetingForCheckIn`); + * `null`, ha ismeretlen. Nem érzékeny, a monitorra is átmegy (TICKET-43). + */ + serviceCategory?: ServiceCategory | null; + /** A szolgáltatás csak nőknek szól („Női Köredzés”). Nem érzékeny, a monitorra is átmegy. */ + womenOnly?: boolean; passName?: string; passType?: PassType; remainingPasses?: number | 'Korlátlan';