feat: username/password authentication for admin and merchant panels
CI/CD / CI · API (dotnet build + test) (push) Successful in 49s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 42s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m8s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Has been cancelled
CI/CD / CI · API (dotnet build + test) (push) Successful in 49s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 42s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m8s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Has been cancelled
- Add PasswordHasher utility (PBKDF2/SHA-256, 100k iterations)
- Add Username + PasswordHash fields to Employee and SystemAdmin entities
- EF migration: AddPasswordLogin (nullable columns on both tables)
- Meezi.API: POST /api/auth/login (employee password login, CHOOSE_CAFE support)
- Meezi.API: PUT/DELETE /api/cafes/{id}/employees/{id}/credentials (Owner/Manager only)
- Meezi.Admin.API: POST /api/admin/auth/login + PUT /api/admin/auth/password
- Dashboard login page: OTP / Password tabs
- Admin login page: OTP / Password tabs
- HR screen: new Credentials tab for setting employee username/password
- PlatformDataSeeder: ensure system admin + integration settings in production
- Trial countdown banner: updated deadline to 1 Tir 1405 (Jun 22)
- i18n: fa/en/ar updated for all new UI strings
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -45,7 +45,14 @@
|
||||
"chooseCafe": "اختر المقهى",
|
||||
"chooseCafeSubtitle": "هذا الرقم لديه صلاحية على عدة مقاهٍ. اختر واحداً للمتابعة.",
|
||||
"createNewCafe": "إنشاء مقهى جديد",
|
||||
"createNewCafeHint": "هل تريد بدء مقهاك الخاص بهذا الرقم؟"
|
||||
"createNewCafeHint": "هل تريد بدء مقهاك الخاص بهذا الرقم؟",
|
||||
"tabOtp": "رمز مؤقت",
|
||||
"tabPassword": "كلمة المرور",
|
||||
"username": "اسم المستخدم",
|
||||
"usernamePlaceholder": "اسم المستخدم",
|
||||
"password": "كلمة المرور",
|
||||
"passwordPlaceholder": "كلمة المرور",
|
||||
"invalidCredentials": "اسم المستخدم أو كلمة المرور غير صحيحة."
|
||||
},
|
||||
"roles": {
|
||||
"owner": "المالك",
|
||||
@@ -386,7 +393,8 @@
|
||||
"attendance": "الحضور",
|
||||
"leave": "الإجازة",
|
||||
"payroll": "الرواتب",
|
||||
"access": "صلاحيات الفروع"
|
||||
"access": "صلاحيات الفروع",
|
||||
"credentials": "بيانات الدخول"
|
||||
},
|
||||
"myAttendance": "حضوري",
|
||||
"clockIn": "تسجيل دخول",
|
||||
@@ -396,7 +404,22 @@
|
||||
"paid": "مدفوع",
|
||||
"markPaid": "تسجيل الدفع",
|
||||
"employeeCount": "الموظفون",
|
||||
"monthYear": "شهر الرواتب"
|
||||
"monthYear": "شهر الرواتب",
|
||||
"credentials": {
|
||||
"title": "بيانات دخول الموظفين",
|
||||
"subtitle": "حدد اسم مستخدم وكلمة مرور لكل موظف حتى يتمكن من تسجيل الدخول دون رمز OTP.",
|
||||
"selectEmployee": "اختر موظفاً أولاً",
|
||||
"username": "اسم المستخدم",
|
||||
"usernamePlaceholder": "مثال: ali_barista",
|
||||
"password": "كلمة المرور (8 أحرف على الأقل)",
|
||||
"passwordPlaceholder": "كلمة مرور جديدة",
|
||||
"set": "حفظ بيانات الدخول",
|
||||
"remove": "حذف بيانات الدخول",
|
||||
"removeConfirm": "هل أنت متأكد؟ لن يتمكن الموظف من تسجيل الدخول بكلمة مرور بعد الآن.",
|
||||
"saved": "تم حفظ بيانات الدخول.",
|
||||
"removed": "تم حذف بيانات الدخول.",
|
||||
"usernameTaken": "اسم المستخدم هذا مستخدم بالفعل."
|
||||
}
|
||||
},
|
||||
"reviews": {
|
||||
"title": "تقييمات العملاء",
|
||||
|
||||
@@ -56,7 +56,14 @@
|
||||
"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?"
|
||||
"createNewCafeHint": "Want to start your own café with this number?",
|
||||
"tabOtp": "One-time code",
|
||||
"tabPassword": "Password",
|
||||
"username": "Username",
|
||||
"usernamePlaceholder": "Username",
|
||||
"password": "Password",
|
||||
"passwordPlaceholder": "Password",
|
||||
"invalidCredentials": "Incorrect username or password."
|
||||
},
|
||||
"roles": {
|
||||
"owner": "Owner",
|
||||
@@ -405,7 +412,8 @@
|
||||
"attendance": "Attendance",
|
||||
"leave": "Leave",
|
||||
"payroll": "Payroll",
|
||||
"access": "Branch access"
|
||||
"access": "Branch access",
|
||||
"credentials": "Login credentials"
|
||||
},
|
||||
"myAttendance": "My attendance",
|
||||
"clockIn": "Clock in",
|
||||
@@ -415,7 +423,22 @@
|
||||
"paid": "Paid",
|
||||
"markPaid": "Mark paid",
|
||||
"employeeCount": "Employees",
|
||||
"monthYear": "Payroll month"
|
||||
"monthYear": "Payroll month",
|
||||
"credentials": {
|
||||
"title": "Employee login credentials",
|
||||
"subtitle": "Set a username and password for each employee so they can sign in without an OTP.",
|
||||
"selectEmployee": "Select an employee first",
|
||||
"username": "Username",
|
||||
"usernamePlaceholder": "e.g. ali_barista",
|
||||
"password": "Password (min 8 characters)",
|
||||
"passwordPlaceholder": "New password",
|
||||
"set": "Save credentials",
|
||||
"remove": "Remove credentials",
|
||||
"removeConfirm": "Are you sure? The employee will no longer be able to sign in with a password.",
|
||||
"saved": "Credentials saved.",
|
||||
"removed": "Credentials removed.",
|
||||
"usernameTaken": "This username is already taken."
|
||||
}
|
||||
},
|
||||
"reviews": {
|
||||
"title": "Customer reviews",
|
||||
|
||||
@@ -56,7 +56,14 @@
|
||||
"chooseCafe": "انتخاب کافه",
|
||||
"chooseCafeSubtitle": "این شماره به چند کافه دسترسی دارد. یکی را انتخاب کنید.",
|
||||
"createNewCafe": "ایجاد کافه جدید",
|
||||
"createNewCafeHint": "میخواهید کافه خودتان را با همین شماره راهاندازی کنید؟"
|
||||
"createNewCafeHint": "میخواهید کافه خودتان را با همین شماره راهاندازی کنید؟",
|
||||
"tabOtp": "کد یکبارمصرف",
|
||||
"tabPassword": "رمز عبور",
|
||||
"username": "نام کاربری",
|
||||
"usernamePlaceholder": "نام کاربری",
|
||||
"password": "رمز عبور",
|
||||
"passwordPlaceholder": "رمز عبور",
|
||||
"invalidCredentials": "نام کاربری یا رمز عبور اشتباه است."
|
||||
},
|
||||
"roles": {
|
||||
"owner": "مالک",
|
||||
@@ -405,7 +412,8 @@
|
||||
"attendance": "حضور و غیاب",
|
||||
"leave": "مرخصی",
|
||||
"payroll": "حقوق",
|
||||
"access": "دسترسی شعب"
|
||||
"access": "دسترسی شعب",
|
||||
"credentials": "رمز ورود"
|
||||
},
|
||||
"myAttendance": "حضور من",
|
||||
"clockIn": "ورود",
|
||||
@@ -415,7 +423,22 @@
|
||||
"paid": "پرداخت شده",
|
||||
"markPaid": "ثبت پرداخت",
|
||||
"employeeCount": "تعداد کارمندان",
|
||||
"monthYear": "ماه حقوق"
|
||||
"monthYear": "ماه حقوق",
|
||||
"credentials": {
|
||||
"title": "مدیریت رمز ورود کارمندان",
|
||||
"subtitle": "برای هر کارمند میتوانید نام کاربری و رمز عبور تعریف کنید تا بدون نیاز به کد OTP وارد شوند.",
|
||||
"selectEmployee": "ابتدا یک کارمند انتخاب کنید",
|
||||
"username": "نام کاربری",
|
||||
"usernamePlaceholder": "مثال: ali_barista",
|
||||
"password": "رمز عبور (حداقل ۸ کاراکتر)",
|
||||
"passwordPlaceholder": "رمز عبور جدید",
|
||||
"set": "ذخیره رمز ورود",
|
||||
"remove": "حذف رمز ورود",
|
||||
"removeConfirm": "آیا مطمئنید؟ کارمند دیگر نمیتواند با رمز عبور وارد شود.",
|
||||
"saved": "رمز ورود ذخیره شد.",
|
||||
"removed": "رمز ورود حذف شد.",
|
||||
"usernameTaken": "این نام کاربری قبلاً استفاده شده است."
|
||||
}
|
||||
},
|
||||
"reviews": {
|
||||
"title": "نظرات مشتریان",
|
||||
|
||||
@@ -12,14 +12,24 @@ import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { OtpInput } from "@/components/ui/otp-input";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
type LoginTab = "otp" | "password";
|
||||
|
||||
export default function LoginPage() {
|
||||
const t = useTranslations("auth");
|
||||
const router = useRouter();
|
||||
const setAuth = useAuthStore((s) => s.setAuth);
|
||||
|
||||
const [tab, setTab] = useState<LoginTab>("otp");
|
||||
|
||||
// OTP state
|
||||
const [phone, setPhone] = useState("09121234567");
|
||||
const [code, setCode] = useState("");
|
||||
const [step, setStep] = useState<"phone" | "otp">("phone");
|
||||
const [otpStep, setOtpStep] = useState<"phone" | "otp">("phone");
|
||||
|
||||
// Password state
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -32,6 +42,9 @@ export default function LoginPage() {
|
||||
return t("smsFailed");
|
||||
case "INVALID_OTP":
|
||||
return t("invalidOtp");
|
||||
case "INVALID_TOKEN":
|
||||
case "NOT_FOUND":
|
||||
return tab === "password" ? t("invalidCredentials") : t("notFound");
|
||||
default:
|
||||
return err.message;
|
||||
}
|
||||
@@ -44,7 +57,7 @@ export default function LoginPage() {
|
||||
setError(null);
|
||||
try {
|
||||
await apiPost("/api/auth/send-otp", { phone });
|
||||
setStep("otp");
|
||||
setOtpStep("otp");
|
||||
} catch (e) {
|
||||
if (e instanceof ApiClientError && e.code === "NOT_FOUND") {
|
||||
// No account → take them to register with phone pre-filled
|
||||
@@ -74,6 +87,34 @@ export default function LoginPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loginWithPassword = async () => {
|
||||
if (!username.trim() || !password) {
|
||||
setError(t("invalidCredentials"));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await apiPost<AuthTokenResponse>("/api/auth/login", {
|
||||
username: username.trim(),
|
||||
password,
|
||||
});
|
||||
setAuth(data);
|
||||
router.push("/pos");
|
||||
} catch (e) {
|
||||
setError(authErrorMessage(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const switchTab = (next: LoginTab) => {
|
||||
setTab(next);
|
||||
setError(null);
|
||||
setOtpStep("phone");
|
||||
setCode("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
@@ -81,8 +122,36 @@ export default function LoginPage() {
|
||||
<CardTitle className="text-center text-primary">{t("title")}</CardTitle>
|
||||
<p className="text-center text-sm text-muted-foreground">{t("subtitle")}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{step === "phone" ? (
|
||||
|
||||
{/* Tab switcher */}
|
||||
<div className="flex border-b px-6">
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 py-2 text-sm font-medium transition-colors cursor-pointer ${
|
||||
tab === "otp"
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => switchTab("otp")}
|
||||
>
|
||||
{t("tabOtp")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 py-2 text-sm font-medium transition-colors cursor-pointer ${
|
||||
tab === "password"
|
||||
? "border-b-2 border-primary text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => switchTab("password")}
|
||||
>
|
||||
{t("tabPassword")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CardContent className="space-y-4 pt-4">
|
||||
{/* ───── OTP tab ───── */}
|
||||
{tab === "otp" && otpStep === "phone" && (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
@@ -105,7 +174,9 @@ export default function LoginPage() {
|
||||
{loading ? "..." : t("sendOtp")}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
)}
|
||||
|
||||
{tab === "otp" && otpStep === "otp" && (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
@@ -128,12 +199,60 @@ export default function LoginPage() {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
onClick={() => setStep("phone")}
|
||||
onClick={() => {
|
||||
setOtpStep("phone");
|
||||
setCode("");
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
{t("resend")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* ───── Password tab ───── */}
|
||||
{tab === "password" && (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!loading) void loginWithPassword();
|
||||
}}
|
||||
>
|
||||
<LabeledField label={t("username")} htmlFor="login-username">
|
||||
<Input
|
||||
id="login-username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder={t("usernamePlaceholder")}
|
||||
dir="ltr"
|
||||
className="text-start"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("password")} htmlFor="login-password">
|
||||
<Input
|
||||
id="login-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
dir="ltr"
|
||||
className="text-start"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</LabeledField>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={loading || !username.trim() || !password}
|
||||
>
|
||||
{loading ? "..." : t("verify")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-center text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { apiPut, apiDelete, ApiClientError } from "@/lib/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
interface Employee {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
cafeId: string;
|
||||
employees: Employee[];
|
||||
}
|
||||
|
||||
export function EmployeeCredentialsPanel({ cafeId, employees }: Props) {
|
||||
const t = useTranslations("hr.credentials");
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string>("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [feedback, setFeedback] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
|
||||
const setMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPut(`/api/cafes/${cafeId}/employees/${selectedId}/credentials`, {
|
||||
username,
|
||||
password,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setFeedback({ ok: true, msg: t("saved") });
|
||||
setPassword("");
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof ApiClientError && err.code === "USERNAME_TAKEN") {
|
||||
setFeedback({ ok: false, msg: t("usernameTaken") });
|
||||
} else {
|
||||
setFeedback({ ok: false, msg: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiDelete(`/api/cafes/${cafeId}/employees/${selectedId}/credentials`),
|
||||
onSuccess: () => {
|
||||
setFeedback({ ok: true, msg: t("removed") });
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
},
|
||||
onError: (err) => {
|
||||
setFeedback({ ok: false, msg: err instanceof Error ? err.message : String(err) });
|
||||
},
|
||||
});
|
||||
|
||||
const handleRemove = () => {
|
||||
if (!window.confirm(t("removeConfirm"))) return;
|
||||
setFeedback(null);
|
||||
removeMutation.mutate();
|
||||
};
|
||||
|
||||
const isPending = setMutation.isPending || removeMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-3">{t("subtitle")}</p>
|
||||
</div>
|
||||
|
||||
{/* Employee selector */}
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{employees.map((emp) => (
|
||||
<button
|
||||
key={emp.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedId(emp.id);
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
setFeedback(null);
|
||||
}}
|
||||
className={`rounded-lg border p-3 text-start transition-colors cursor-pointer ${
|
||||
selectedId === emp.id
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<p className="font-medium text-sm">{emp.name}</p>
|
||||
<p className="text-xs text-muted-foreground" dir="ltr">{emp.phone}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
{selectedId && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{employees.find((e) => e.id === selectedId)?.name}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<LabeledField label={t("username")} htmlFor="cred-username">
|
||||
<Input
|
||||
id="cred-username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder={t("usernamePlaceholder")}
|
||||
dir="ltr"
|
||||
className="text-start"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("password")} htmlFor="cred-password">
|
||||
<Input
|
||||
id="cred-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
dir="ltr"
|
||||
className="text-start"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</LabeledField>
|
||||
|
||||
{feedback && (
|
||||
<p className={`text-sm ${feedback.ok ? "text-green-600" : "text-destructive"}`}>
|
||||
{feedback.msg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setFeedback(null);
|
||||
setMutation.mutate();
|
||||
}}
|
||||
disabled={isPending || !username.trim() || password.length < 8}
|
||||
>
|
||||
{isPending ? "..." : t("set")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRemove}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t("remove")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!selectedId && (
|
||||
<p className="text-sm text-muted-foreground">{t("selectEmployee")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { BranchAccessPanel } from "@/components/hr/branch-access-panel";
|
||||
import { EmployeeCredentialsPanel } from "@/components/hr/employee-credentials-panel";
|
||||
|
||||
interface Employee {
|
||||
id: string;
|
||||
@@ -47,7 +48,7 @@ interface Salary {
|
||||
isPaid: boolean;
|
||||
}
|
||||
|
||||
type Tab = "attendance" | "leave" | "payroll" | "access";
|
||||
type Tab = "attendance" | "leave" | "payroll" | "access" | "credentials";
|
||||
|
||||
export function HrScreen() {
|
||||
const t = useTranslations("hr");
|
||||
@@ -122,8 +123,8 @@ export function HrScreen() {
|
||||
<h2 className="text-xl font-bold">{t("title")}</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{((["attendance", "leave", "payroll", "access"] as Tab[]).filter(
|
||||
(key) => key !== "access" || canManageAccess
|
||||
{((["attendance", "leave", "payroll", "access", "credentials"] as Tab[]).filter(
|
||||
(key) => (key !== "access" && key !== "credentials") || canManageAccess
|
||||
)).map((key) => (
|
||||
<Button
|
||||
key={key}
|
||||
@@ -230,6 +231,10 @@ export function HrScreen() {
|
||||
)}
|
||||
|
||||
{tab === "access" && canManageAccess && <BranchAccessPanel cafeId={cafeId} />}
|
||||
|
||||
{tab === "credentials" && canManageAccess && (
|
||||
<EmployeeCredentialsPanel cafeId={cafeId} employees={employees} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import { useLocale } from "next-intl";
|
||||
import { useRouter } from "@/i18n/routing";
|
||||
import { Clock, X, Zap } from "lucide-react";
|
||||
|
||||
// 14 Khordad 1405 = June 4, 2026 (Tehran UTC+3:30)
|
||||
const DEADLINE = new Date("2026-06-04T00:00:00+03:30");
|
||||
// 1 Tir 1405 = June 22, 2026 (Tehran IRDT UTC+4:30)
|
||||
const DEADLINE = new Date("2026-06-22T00:00:00+04:30");
|
||||
const STORAGE_KEY = "meezi_trial_banner_v1";
|
||||
|
||||
interface TimeLeft {
|
||||
@@ -78,11 +78,11 @@ export function TrialCountdownBanner() {
|
||||
|
||||
const textFa = expired
|
||||
? "دوره آزمایشی میزی به پایان رسید. برای ادامه پلن انتخاب کنید."
|
||||
: "دوره آزمایشی رایگان تا ۱۴ خرداد ۱۴۰۵";
|
||||
: "دوره آزمایشی رایگان تا ۱ تیر ۱۴۰۵";
|
||||
|
||||
const textEn = expired
|
||||
? "Your Meezi trial has ended. Choose a plan to continue."
|
||||
: "Free trial ends 14 Khordad 1405 (Jun 4)";
|
||||
: "Free trial ends 1 Tir 1405 (Jun 22)";
|
||||
|
||||
const Digit = ({ value, label }: { value: number; label: string }) => (
|
||||
<div className="flex flex-col items-center">
|
||||
|
||||
Reference in New Issue
Block a user