diff --git a/src/Meezi.API/Controllers/AuthController.cs b/src/Meezi.API/Controllers/AuthController.cs index b2a1db9..d4f7fd4 100644 --- a/src/Meezi.API/Controllers/AuthController.cs +++ b/src/Meezi.API/Controllers/AuthController.cs @@ -6,6 +6,7 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using Meezi.API.Models.Auth; using Meezi.API.Services; +using Meezi.API.Services; using Meezi.Core.Constants; using Meezi.Shared; @@ -62,7 +63,28 @@ public class AuthController : ControllerBase if (!validation.IsValid) return BadRequest(ValidationError(validation)); - var (success, data, code, message) = await _authService.VerifyOtpAsync(request, cancellationToken); + var (success, data, code, message, choices) = await _authService.VerifyOtpAsync(request, cancellationToken); + + if (!success && code == "CHOOSE_CAFE") + return Ok(new ApiResponse(false, choices, new ApiError("CHOOSE_CAFE", "Please select a café to continue."))); + + if (!success) + return ErrorResult(code!, message!); + + return Ok(new ApiResponse(true, data)); + } + + [HttpPost("switch-cafe")] + [Authorize] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task SwitchCafe([FromBody] SwitchCafeRequest request, CancellationToken cancellationToken) + { + var userId = User.FindFirstValue(JwtRegisteredClaimNames.Sub) + ?? User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(userId)) + return Unauthorized(); + + var (success, data, code, message) = await _authService.SwitchCafeAsync(userId, request.CafeId, cancellationToken); if (!success) return ErrorResult(code!, message!); diff --git a/src/Meezi.API/Models/Auth/AuthDtos.cs b/src/Meezi.API/Models/Auth/AuthDtos.cs index 7856f46..f172781 100644 --- a/src/Meezi.API/Models/Auth/AuthDtos.cs +++ b/src/Meezi.API/Models/Auth/AuthDtos.cs @@ -6,12 +6,17 @@ public record VerifyOtpRequest(string Phone, string Code, string? CafeId = null) public record RefreshTokenRequest(string RefreshToken); +public record SwitchCafeRequest(string CafeId); + /// Step 1 of self-registration: send OTP to a new phone number. public record RegisterRequest(string Phone, string CafeName); /// Step 2 of self-registration: verify OTP and create the cafe account. public record VerifyRegisterRequest(string Phone, string Code); +/// One café membership entry returned when user belongs to multiple cafés. +public record CafeMembershipDto(string CafeId, string CafeName, string Role, string PlanTier); + public record AuthTokenResponse( string AccessToken, string RefreshToken, @@ -22,6 +27,10 @@ public record AuthTokenResponse( string PlanTier, string Language, string Actor = Meezi.Core.Constants.MeeziActorKinds.Merchant, - string? BranchId = null); + string? BranchId = null, + List? Memberships = null); public record SendOtpResponse(bool Sent, int ExpiresInSeconds); + +/// Returned when a phone number belongs to multiple cafés and no CafeId was specified. +public record CafeChoicesResponse(List Cafes); diff --git a/src/Meezi.API/Services/AuthService.cs b/src/Meezi.API/Services/AuthService.cs index cd5bff7..b260a44 100644 --- a/src/Meezi.API/Services/AuthService.cs +++ b/src/Meezi.API/Services/AuthService.cs @@ -80,9 +80,6 @@ public class AuthService : IAuthService var otp = Random.Shared.Next(100000, 999999).ToString(); await redis.StringSetAsync($"otp:{phone}", otp, TimeSpan.FromSeconds(OtpTtlSeconds)); - if (string.IsNullOrWhiteSpace(_configuration["Kavenegar:ApiKey"])) - _logger.LogWarning("DEV OTP for {Phone}: {Otp} (configure Kavenegar:ApiKey to send SMS)", phone, otp); - try { await _smsService.SendOtpAsync(phone, otp, cancellationToken); @@ -105,20 +102,20 @@ public class AuthService : IAuthService return (true, new SendOtpResponse(true, OtpTtlSeconds), null, null); } - public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> VerifyOtpAsync( + public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage, CafeChoicesResponse? Choices)> VerifyOtpAsync( VerifyOtpRequest request, CancellationToken cancellationToken = default) { var phone = PhoneNormalizer.Normalize(request.Phone); var code = OtpNormalizer.Normalize(request.Code); if (!OtpNormalizer.IsValidSixDigitCode(code)) - return (false, null, "INVALID_OTP", "Invalid or expired verification code."); + return (false, null, "INVALID_OTP", "Invalid or expired verification code.", null); var redis = _redis.GetDatabase(); var storedOtp = await redis.StringGetAsync($"otp:{phone}"); if (storedOtp.IsNullOrEmpty || storedOtp.ToString() != code) - return (false, null, "INVALID_OTP", "Invalid or expired verification code."); + return (false, null, "INVALID_OTP", "Invalid or expired verification code.", null); var query = _db.Employees .Include(e => e.Cafe) @@ -129,17 +126,68 @@ public class AuthService : IAuthService var matches = await query.ToListAsync(cancellationToken); if (matches.Count == 0) - return (false, null, "NOT_FOUND", "No account found for this phone number."); + return (false, null, "NOT_FOUND", "No account found for this phone number.", null); + + // Multiple cafés — ask frontend to pick one (OTP kept alive for the 2nd call) if (matches.Count > 1) - return (false, null, "MULTIPLE_ACCOUNTS", "Multiple accounts use this phone. Contact your cafe owner."); + { + var choices = new CafeChoicesResponse( + matches + .Where(e => e.Cafe is not null) + .Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString())) + .ToList()); + return (false, null, "CHOOSE_CAFE", null, choices); + } var employee = matches[0]; if (employee.Cafe is null) - return (false, null, "NOT_FOUND", "No account found for this phone number."); + return (false, null, "NOT_FOUND", "No account found for this phone number.", null); await redis.KeyDeleteAsync($"otp:{phone}"); - var tokens = await IssueTokensAsync(employee, employee.Cafe, cancellationToken); + // Fetch all memberships for this phone to include in the token response + var allMemberships = await _db.Employees + .Include(e => e.Cafe) + .Where(e => e.Phone == phone && e.DeletedAt == null) + .ToListAsync(cancellationToken); + + var membershipDtos = allMemberships + .Where(e => e.Cafe is not null) + .Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString())) + .ToList(); + + var tokens = await IssueTokensAsync(employee, employee.Cafe, membershipDtos, cancellationToken); + return (true, tokens, null, null, null); + } + + public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> SwitchCafeAsync( + string employeeId, string targetCafeId, + CancellationToken cancellationToken = default) + { + // Find the current employee to get their phone + var currentEmployee = await _db.Employees + .FirstOrDefaultAsync(e => e.Id == employeeId && e.DeletedAt == null, cancellationToken); + if (currentEmployee is null) + return (false, null, "NOT_FOUND", "User not found."); + + // Find their membership in the target café + var targetEmployee = await _db.Employees + .Include(e => e.Cafe) + .FirstOrDefaultAsync(e => e.Phone == currentEmployee.Phone && e.CafeId == targetCafeId && e.DeletedAt == null, cancellationToken); + if (targetEmployee?.Cafe is null) + return (false, null, "NOT_FOUND", "You don't have access to this café."); + + var allMemberships = await _db.Employees + .Include(e => e.Cafe) + .Where(e => e.Phone == currentEmployee.Phone && e.DeletedAt == null) + .ToListAsync(cancellationToken); + + var membershipDtos = allMemberships + .Where(e => e.Cafe is not null) + .Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString())) + .ToList(); + + var tokens = await IssueTokensAsync(targetEmployee, targetEmployee.Cafe, membershipDtos, cancellationToken); return (true, tokens, null, null); } @@ -160,7 +208,17 @@ public class AuthService : IAuthService await _refreshTokenStore.RevokeAsync(request.RefreshToken, cancellationToken); - var tokens = await IssueTokensAsync(employee, employee.Cafe, cancellationToken); + var allMemberships = await _db.Employees + .Include(e => e.Cafe) + .Where(e => e.Phone == employee.Phone && e.DeletedAt == null) + .ToListAsync(cancellationToken); + + var membershipDtos = allMemberships + .Where(e => e.Cafe is not null) + .Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString())) + .ToList(); + + var tokens = await IssueTokensAsync(employee, employee.Cafe, membershipDtos, cancellationToken); return (true, tokens, null, null); } @@ -201,9 +259,6 @@ public class AuthService : IAuthService // Store the cafe name alongside the OTP so verify-register can create the cafe await redis.StringSetAsync($"reg_meta:{phone}", cafeName, TimeSpan.FromSeconds(OtpTtlSeconds)); - if (string.IsNullOrWhiteSpace(_configuration["Kavenegar:ApiKey"])) - _logger.LogWarning("DEV REGISTER OTP for {Phone}: {Otp} (configure Kavenegar:ApiKey to send SMS)", phone, otp); - try { await _smsService.SendOtpAsync(phone, otp, cancellationToken); @@ -282,7 +337,11 @@ public class AuthService : IAuthService _logger.LogInformation("New cafe registered: {CafeId} by phone ending {Suffix}", cafe.Id, phone[^4..]); - var tokens = await IssueTokensAsync(owner, cafe, cancellationToken); + var ownerMembership = new List + { + new(cafe.Id, cafe.Name, owner.Role.ToString(), cafe.PlanTier.ToString()) + }; + var tokens = await IssueTokensAsync(owner, cafe, ownerMembership, cancellationToken); return (true, tokens, null, null); } @@ -300,6 +359,7 @@ public class AuthService : IAuthService private async Task IssueTokensAsync( Core.Entities.Employee employee, Core.Entities.Cafe cafe, + List? memberships, CancellationToken cancellationToken) { var accessToken = _jwtTokenService.CreateAccessToken(employee, cafe); @@ -328,6 +388,7 @@ public class AuthService : IAuthService cafe.PlanTier.ToString(), cafe.PreferredLanguage, Meezi.Core.Constants.MeeziActorKinds.Merchant, - employee.BranchId); + employee.BranchId, + memberships); } } diff --git a/src/Meezi.API/Services/IAuthService.cs b/src/Meezi.API/Services/IAuthService.cs index 39b3f52..9b84204 100644 --- a/src/Meezi.API/Services/IAuthService.cs +++ b/src/Meezi.API/Services/IAuthService.cs @@ -8,10 +8,18 @@ public interface IAuthService SendOtpRequest request, CancellationToken cancellationToken = default); - Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> VerifyOtpAsync( + /// + /// Returns either an AuthTokenResponse (single café) or error code CHOOSE_CAFE + /// with CafeChoicesResponse serialised in ErrorMessage when multiple cafés found. + /// + Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage, CafeChoicesResponse? Choices)> VerifyOtpAsync( VerifyOtpRequest request, CancellationToken cancellationToken = default); + Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> SwitchCafeAsync( + string employeeId, string targetCafeId, + CancellationToken cancellationToken = default); + Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> RefreshAsync( RefreshTokenRequest request, CancellationToken cancellationToken = default); diff --git a/src/Meezi.Admin.API/Extensions/AdminServiceCollectionExtensions.cs b/src/Meezi.Admin.API/Extensions/AdminServiceCollectionExtensions.cs index 60845b6..f4363ac 100644 --- a/src/Meezi.Admin.API/Extensions/AdminServiceCollectionExtensions.cs +++ b/src/Meezi.Admin.API/Extensions/AdminServiceCollectionExtensions.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; using Meezi.Admin.API.Hubs; using Meezi.Admin.API.Services; +using Meezi.Admin.API.Models; using Meezi.Admin.API.Validators; using Meezi.Infrastructure; using Serilog; @@ -38,6 +39,10 @@ public static class AdminServiceCollectionExtensions services.AddSwaggerGen(); services.AddSignalR(); services.AddValidatorsFromAssemblyContaining(); + // Explicit registrations as safety net (assembly scan can miss in some Docker layer caches) + services.AddScoped, SendOtpRequestValidator>(); + services.AddScoped, VerifyOtpRequestValidator>(); + services.AddScoped, RefreshTokenRequestValidator>(); var jwtKey = configuration["Jwt:Key"] ?? "meezi-dev-secret-key-min-32-chars!!"; var jwtIssuer = configuration["Jwt:Issuer"] ?? "meezi"; diff --git a/web/dashboard/messages/ar.json b/web/dashboard/messages/ar.json index 74ce33c..b346722 100644 --- a/web/dashboard/messages/ar.json +++ b/web/dashboard/messages/ar.json @@ -41,7 +41,20 @@ "rateLimited": "طلبات الرمز كثيرة جداً. انتظر ساعة كحد أقصى أو تواصل مع الدعم.", "notFound": "لا يوجد حساب بهذا الرقم.", "smsFailed": "فشل إرسال الرسالة. حاول مرة أخرى.", - "invalidOtp": "رمز التحقق غير صحيح أو منتهٍ." + "invalidOtp": "رمز التحقق غير صحيح أو منتهٍ.", + "chooseCafe": "اختر المقهى", + "chooseCafeSubtitle": "هذا الرقم لديه صلاحية على عدة مقاهٍ. اختر واحداً للمتابعة.", + "createNewCafe": "إنشاء مقهى جديد", + "createNewCafeHint": "هل تريد بدء مقهاك الخاص بهذا الرقم؟" + }, + "roles": { + "owner": "المالك", + "manager": "المدير", + "cashier": "أمين الصندوق", + "waiter": "النادل", + "chef": "الطاهي", + "delivery": "عامل التوصيل", + "unknown": "مستخدم" }, "nav": { "aria": "القائمة الرئيسية", diff --git a/web/dashboard/messages/en.json b/web/dashboard/messages/en.json index 0f3e3e8..4d3fa7c 100644 --- a/web/dashboard/messages/en.json +++ b/web/dashboard/messages/en.json @@ -52,7 +52,20 @@ "noAccount": "Don't have an account?", "registerLink": "Register", "alreadyRegistered": "This phone is already registered. Please sign in.", - "registrationExpired": "Registration session expired. Please try again." + "registrationExpired": "Registration session expired. Please try again.", + "chooseCafe": "Choose a café", + "chooseCafeSubtitle": "This number has access to several cafés. Pick one to continue.", + "createNewCafe": "Create a new café", + "createNewCafeHint": "Want to start your own café with this number?" + }, + "roles": { + "owner": "Owner", + "manager": "Manager", + "cashier": "Cashier", + "waiter": "Waiter", + "chef": "Chef", + "delivery": "Delivery", + "unknown": "User" }, "nav": { "aria": "Main navigation", @@ -93,7 +106,13 @@ "offline": "Offline", "activePlan": "Active plan", "editCafeSettings": "Café settings", - "viewSubscription": "Plan & billing" + "viewSubscription": "Plan & billing", + "switchCafe": "Switch café", + "currentCafe": "Current café", + "otherCafes": "Other cafés", + "createNewCafe": "Create a new café", + "openMenu": "Menu", + "switchCafeError": "Could not switch café. Please try again." }, "overview": { "title": "Home", diff --git a/web/dashboard/messages/fa.json b/web/dashboard/messages/fa.json index ed6808d..c4a4974 100644 --- a/web/dashboard/messages/fa.json +++ b/web/dashboard/messages/fa.json @@ -52,7 +52,20 @@ "noAccount": "حساب ندارید؟", "registerLink": "ثبت‌نام", "alreadyRegistered": "این شماره قبلاً ثبت‌نام کرده است. لطفاً وارد شوید.", - "registrationExpired": "زمان ثبت‌نام منقضی شد. دوباره تلاش کنید." + "registrationExpired": "زمان ثبت‌نام منقضی شد. دوباره تلاش کنید.", + "chooseCafe": "انتخاب کافه", + "chooseCafeSubtitle": "این شماره به چند کافه دسترسی دارد. یکی را انتخاب کنید.", + "createNewCafe": "ایجاد کافه جدید", + "createNewCafeHint": "می‌خواهید کافه خودتان را با همین شماره راه‌اندازی کنید؟" + }, + "roles": { + "owner": "مالک", + "manager": "مدیر", + "cashier": "صندوق‌دار", + "waiter": "گارسون", + "chef": "آشپز", + "delivery": "پیک", + "unknown": "کاربر" }, "nav": { "aria": "منوی اصلی", @@ -93,7 +106,13 @@ "offline": "آفلاین", "activePlan": "پلن فعال", "editCafeSettings": "تنظیمات کافه", - "viewSubscription": "اشتراک و پلن" + "viewSubscription": "اشتراک و پلن", + "switchCafe": "تغییر کافه", + "currentCafe": "کافه فعلی", + "otherCafes": "کافه‌های دیگر", + "createNewCafe": "ایجاد کافه جدید", + "openMenu": "منو", + "switchCafeError": "تغییر کافه ناموفق بود. دوباره تلاش کنید." }, "overview": { "title": "خانه", diff --git a/web/dashboard/src/app/[locale]/login/page.tsx b/web/dashboard/src/app/[locale]/login/page.tsx index 7a9fe69..17d79e5 100644 --- a/web/dashboard/src/app/[locale]/login/page.tsx +++ b/web/dashboard/src/app/[locale]/login/page.tsx @@ -9,6 +9,7 @@ import { useAuthStore } from "@/lib/stores/auth.store"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { LabeledField } from "@/components/ui/labeled-field"; +import { OtpInput } from "@/components/ui/otp-input"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; export default function LoginPage() { @@ -113,18 +114,14 @@ export default function LoginPage() { }} > - setCode(e.target.value)} - placeholder={t("otpPlaceholder")} - maxLength={6} - dir="ltr" - className="text-center tracking-widest" - autoComplete="one-time-code" + onChange={setCode} + autoFocus + disabled={loading} /> - + +
+ + {/* Dashboard shortcut — only visible to Owner / Manager */} + {isManager && ( + + + {cafeName} + + )}
{/* ── Pay mode ──────────────────────────────────────────────────────── */} diff --git a/web/dashboard/src/components/ui/otp-input.tsx b/web/dashboard/src/components/ui/otp-input.tsx new file mode 100644 index 0000000..6f6090b --- /dev/null +++ b/web/dashboard/src/components/ui/otp-input.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useRef, KeyboardEvent, ClipboardEvent } from "react"; +import { cn } from "@/lib/utils"; + +interface OtpInputProps { + value: string; + onChange: (value: string) => void; + length?: number; + disabled?: boolean; + autoFocus?: boolean; +} + +export function OtpInput({ + value, + onChange, + length = 6, + disabled = false, + autoFocus = false, +}: OtpInputProps) { + const inputsRef = useRef<(HTMLInputElement | null)[]>([]); + + const digits = Array.from({ length }, (_, i) => value[i] ?? ""); + + const focus = (index: number) => { + inputsRef.current[index]?.focus(); + }; + + const handleChange = (index: number, char: string) => { + // Accept only digits + const digit = char.replace(/\D/g, "").slice(-1); + const next = digits.map((d, i) => (i === index ? digit : d)).join(""); + onChange(next); + if (digit && index < length - 1) focus(index + 1); + }; + + const handleKeyDown = (index: number, e: KeyboardEvent) => { + if (e.key === "Backspace") { + if (digits[index]) { + const next = digits.map((d, i) => (i === index ? "" : d)).join(""); + onChange(next); + } else if (index > 0) { + focus(index - 1); + } + } else if (e.key === "ArrowLeft") { + focus(Math.max(0, index - 1)); + } else if (e.key === "ArrowRight") { + focus(Math.min(length - 1, index + 1)); + } + }; + + const handlePaste = (e: ClipboardEvent) => { + e.preventDefault(); + const pasted = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, length); + if (!pasted) return; + onChange(pasted.padEnd(length, "").slice(0, length).replace(/ /g, "")); + // Actually just set what was pasted + const filled = pasted.slice(0, length); + onChange(filled); + focus(Math.min(filled.length, length - 1)); + }; + + return ( +
+ {digits.map((digit, i) => ( + { inputsRef.current[i] = el; }} + type="text" + inputMode="numeric" + pattern="[0-9]*" + maxLength={1} + value={digit} + disabled={disabled} + autoFocus={autoFocus && i === 0} + autoComplete={i === 0 ? "one-time-code" : "off"} + onChange={(e) => handleChange(i, e.target.value)} + onKeyDown={(e) => handleKeyDown(i, e)} + onPaste={handlePaste} + onFocus={(e) => e.target.select()} + className={cn( + "h-12 w-10 rounded-lg border-2 bg-background text-center text-lg font-semibold", + "transition-all duration-150 outline-none", + "border-border", + "focus:border-primary focus:ring-2 focus:ring-primary/20", + digit && "border-primary/60 bg-primary/5", + disabled && "cursor-not-allowed opacity-50", + )} + /> + ))} +
+ ); +} diff --git a/web/dashboard/src/lib/api/client.ts b/web/dashboard/src/lib/api/client.ts index bfe5c67..2f86778 100644 --- a/web/dashboard/src/lib/api/client.ts +++ b/web/dashboard/src/lib/api/client.ts @@ -76,7 +76,9 @@ export async function apiGetPaged(url: string): Promise<{ items: T[]; meta: P export class ApiClientError extends Error { constructor( public readonly code: string, - message: string + message: string, + /** Payload returned alongside a non-success response (e.g. CHOOSE_CAFE choices). */ + public readonly payload?: unknown ) { super(message); this.name = "ApiClientError"; @@ -87,7 +89,7 @@ export async function apiPost(url: string, body?: B): Promise const { data } = await api.post>(url, body); if (!data.success || data.data === undefined) { const code = data.error?.code ?? "REQUEST_FAILED"; - throw new ApiClientError(code, data.error?.message ?? "Request failed"); + throw new ApiClientError(code, data.error?.message ?? "Request failed", data.data); } return data.data; } diff --git a/web/dashboard/src/lib/api/types.ts b/web/dashboard/src/lib/api/types.ts index e747a39..f5ee446 100644 --- a/web/dashboard/src/lib/api/types.ts +++ b/web/dashboard/src/lib/api/types.ts @@ -4,6 +4,13 @@ export interface ApiResponse { error?: { code: string; message: string; field?: string }; } +export interface CafeMembership { + cafeId: string; + cafeName: string; + role: string; + planTier: string; +} + export interface AuthTokenResponse { accessToken: string; refreshToken: string; @@ -15,6 +22,12 @@ export interface AuthTokenResponse { language: string; actor?: string; branchId?: string | null; + memberships?: CafeMembership[] | null; +} + +/** Returned (in the data field) when a phone belongs to multiple cafés. */ +export interface CafeChoicesResponse { + cafes: CafeMembership[]; } export interface MenuCategory { diff --git a/web/dashboard/src/lib/role-label.ts b/web/dashboard/src/lib/role-label.ts new file mode 100644 index 0000000..8649019 --- /dev/null +++ b/web/dashboard/src/lib/role-label.ts @@ -0,0 +1,45 @@ +/** + * Maps backend EmployeeRole names to i18n keys under the "roles" namespace. + * Backend enum: Owner, Manager, Cashier, Waiter, Chef, Delivery. + */ +export type EmployeeRoleName = + | "Owner" + | "Manager" + | "Cashier" + | "Waiter" + | "Chef" + | "Delivery"; + +export const ROLE_KEYS: Record = { + Owner: "owner", + Manager: "manager", + Cashier: "cashier", + Waiter: "waiter", + Chef: "chef", + Delivery: "delivery", +}; + +export function roleKey(role: string | undefined | null): string { + if (!role) return "unknown"; + return ROLE_KEYS[role] ?? "unknown"; +} + +/** Tailwind classes for a colored role badge. */ +export function roleBadgeClass(role: string | undefined | null): string { + switch (role) { + case "Owner": + return "bg-primary/10 text-primary border-primary/30"; + case "Manager": + return "bg-violet-50 text-violet-700 border-violet-200"; + case "Cashier": + return "bg-blue-50 text-blue-700 border-blue-200"; + case "Chef": + return "bg-amber-50 text-amber-700 border-amber-200"; + case "Waiter": + return "bg-emerald-50 text-emerald-700 border-emerald-200"; + case "Delivery": + return "bg-orange-50 text-orange-700 border-orange-200"; + default: + return "bg-muted text-muted-foreground border-border"; + } +}