Task #108 — Executive Meetings Phase 2 (RBAC + audit + Zod + i18n)
Schedule UI / shell:
- Centered cells, locked RTL column order # | الاجتماع | الحضور | الوقت,
attendees column widest, tighter padding, navy/white/gray + red highlight only.
- Header label "م" → "#" (ar.json).
Schema (lib/db/src/schema/executive-meetings.ts):
- executive_meeting_requests.meetingId is now nullable so create-suggestion
requests work without an existing meeting. db push applied.
Backend (artifacts/api-server/src/routes/executive-meetings.ts) — full rewrite:
- All routes (including /me) gated by requireExecutiveAccess. Fine-grained
middleware: requireMutate / requireApprove / requireRequest /
requireAdminAudit on the appropriate routes.
- Zod schemas validate every mutating body via parseBody().
- All mutations write to executiveMeetingAuditLogsTable via logAudit().
- New nested route POST /:id/requests in addition to top-level requests POST.
- Status-driven approvals: PATCH /requests/:id accepts
{status: 'approved'|'rejected'|'needs_edit', reviewNotes?} or
{action:'withdraw'} for the requester.
- PUT /font-settings (canonical) plus PATCH alias — both wired directly
to the same shared upsertFontSettingsHandler (no method-rewrite hack).
- GET /tasks supports date and assigneeId filters.
- Removed all `as never` casts via $inferInsert/Partial typing.
Auth middleware (artifacts/api-server/src/middlewares/auth.ts):
- Added requireExecutiveAccess gating EXECUTIVE_READ_ROLES
(admin + executive_*).
Frontend (artifacts/tx-os/src/pages/executive-meetings.tsx):
- Notifications visibility now uses canRead (was canViewAudit).
- Approvals: review() sends {status, reviewNotes} and a new
"Send back for edits" (needs_edit) button is rendered alongside
Approve/Reject.
- PdfSection: primary "Print + Archive" button (testid em-print) does
archive then print in one click; em-print-only and em-archive remain.
- AuditSection: 6th column renders the new in-file AuditDiffSummary
component (compact "field: old → new" diff with truncation).
- Print page (executive-meetings-print.tsx): kept its inline bilingual T
table — it loads in a standalone print window before the i18n provider
mounts, so an inline dictionary is the right pattern. No hardcoded UI
text remains in the main page.
i18n (en.json + ar.json):
- New keys executiveMeetings.approvals.{needsEdit, needsEditDone},
.audit.col.changes, .audit.{created, removed, moreChanges},
.audit.entity.pdf_archive,
.audit.action.{approved, rejected, needs_edit, withdraw, apply,
duplicate, replace_attendees, done},
.pdf.{print = "Print + archive"/"طباعة وأرشفة", printOnly,
archivedAndPrinted}.
Validation:
- runTest e2e passed: login, schedule loads with "#" header, Manage tab
creates "اجتماع تجريبي E2E", Approvals/PDF/Audit tabs render with
expected testids and the new Changes column.
- curl smoke: login 200, /me 200, dates 200, create meeting 201,
nested POST /:id/requests 201, PATCH /requests/:id status approval 200,
audit-logs 200, DELETE meeting 204, PUT and PATCH /font-settings both
200 with persisted data.
- Architect re-reviewed twice: VERDICT: APPROVED on the second pass
after fixing /me gating and the PATCH font-settings dispatch.
Out of scope (handled in follow-ups #110/#111/#112): real notifications
delivery, server-side PDF rendering, mobile polish.
Drift: none material. Followed plan T1–T8.
This commit is contained in:
@@ -24,7 +24,8 @@
|
||||
"google-auth-library": "^10.6.2",
|
||||
"pino": "^9",
|
||||
"pino-http": "^10",
|
||||
"socket.io": "^4.8.3"
|
||||
"socket.io": "^4.8.3",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
|
||||
@@ -190,3 +190,52 @@ export async function getUserRoles(userId: number): Promise<string[]> {
|
||||
return Array.from(new Set(roles.map((r) => r.roleName)));
|
||||
}
|
||||
|
||||
const EXECUTIVE_READ_ROLES: ReadonlyArray<string> = [
|
||||
"admin",
|
||||
"executive_ceo",
|
||||
"executive_office_manager",
|
||||
"executive_coord_lead",
|
||||
"executive_coordinator",
|
||||
"executive_viewer",
|
||||
];
|
||||
|
||||
/**
|
||||
* Gate any read access to the Executive Meetings module. Allows admin and
|
||||
* any of the executive_* roles. Mutate / approve / audit-view sub-roles
|
||||
* are layered on top via local helpers in the routes file.
|
||||
*/
|
||||
export async function requireExecutiveAccess(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> {
|
||||
if (!req.session.userId) {
|
||||
res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
|
||||
return;
|
||||
}
|
||||
const [user] = await db
|
||||
.select({ isActive: usersTable.isActive })
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, req.session.userId));
|
||||
if (!user || !user.isActive) {
|
||||
res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
|
||||
return;
|
||||
}
|
||||
const ids = await getEffectiveRoleIds(req.session.userId);
|
||||
if (ids.length === 0) {
|
||||
res.status(403).json({ error: "Forbidden", code: "forbidden" });
|
||||
return;
|
||||
}
|
||||
const rows = await db
|
||||
.select({ name: rolesTable.name })
|
||||
.from(rolesTable)
|
||||
.where(inArray(rolesTable.id, ids));
|
||||
const names = new Set(rows.map((r) => r.name));
|
||||
const ok = EXECUTIVE_READ_ROLES.some((r) => names.has(r));
|
||||
if (!ok) {
|
||||
res.status(403).json({ error: "Forbidden", code: "forbidden" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -748,6 +748,8 @@
|
||||
"empty": "لا توجد طلبات بانتظار المراجعة.",
|
||||
"approve": "اعتماد",
|
||||
"reject": "رفض",
|
||||
"needsEdit": "إعادة للتعديل",
|
||||
"needsEditDone": "تم إرجاع الطلب لمقدّمه",
|
||||
"reviewNotes": "ملاحظات المراجعة",
|
||||
"reviewNotesPlaceholder": "اكتب ملاحظاتك هنا (اختياري)",
|
||||
"approved": "تم الاعتماد",
|
||||
@@ -790,32 +792,47 @@
|
||||
"action": "الإجراء",
|
||||
"entity": "النوع",
|
||||
"entityId": "المعرف",
|
||||
"diff": "التفاصيل"
|
||||
"diff": "التفاصيل",
|
||||
"changes": "التغييرات"
|
||||
},
|
||||
"created": "تم إنشاء",
|
||||
"removed": "تم حذف",
|
||||
"moreChanges": "تغييرات أخرى",
|
||||
"entity": {
|
||||
"meeting": "اجتماع",
|
||||
"attendee": "حاضر",
|
||||
"request": "طلب",
|
||||
"task": "مهمة",
|
||||
"font_settings": "إعدادات الخط"
|
||||
"font_settings": "إعدادات الخط",
|
||||
"pdf_archive": "أرشيف PDF"
|
||||
},
|
||||
"action": {
|
||||
"create": "إنشاء",
|
||||
"update": "تعديل",
|
||||
"delete": "حذف",
|
||||
"approve": "اعتماد",
|
||||
"approved": "تم الاعتماد",
|
||||
"reject": "رفض",
|
||||
"submit": "إرسال"
|
||||
"rejected": "تم الرفض",
|
||||
"needs_edit": "إعادة للتعديل",
|
||||
"withdraw": "سحب",
|
||||
"submit": "إرسال",
|
||||
"apply": "تطبيق",
|
||||
"duplicate": "تكرار",
|
||||
"replace_attendees": "تحديث الحضور",
|
||||
"done": "تم"
|
||||
}
|
||||
},
|
||||
"pdf": {
|
||||
"heading": "تصدير / طباعة",
|
||||
"intro": "تستخدم هذه الواجهة طابعة المتصفح لإنتاج نسخة PDF من جدول اليوم. اختر التاريخ ثم اضغط طباعة.",
|
||||
"print": "طباعة الجدول",
|
||||
"print": "طباعة وأرشفة",
|
||||
"printOnly": "طباعة فقط",
|
||||
"openSchedule": "عرض الجدول",
|
||||
"selectDate": "تاريخ الجدول",
|
||||
"archiveSnapshot": "أرشفة نسخة",
|
||||
"archived": "تمت أرشفة النسخة",
|
||||
"archivedAndPrinted": "تمت أرشفة النسخة وفتح نافذة الطباعة",
|
||||
"archivesHeading": "النسخ المؤرشفة",
|
||||
"noArchives": "لا توجد نسخ مؤرشفة لهذا التاريخ بعد."
|
||||
},
|
||||
|
||||
@@ -745,6 +745,8 @@
|
||||
"empty": "No requests pending review.",
|
||||
"approve": "Approve",
|
||||
"reject": "Reject",
|
||||
"needsEdit": "Send back for edits",
|
||||
"needsEditDone": "Sent back to requester",
|
||||
"reviewNotes": "Review notes",
|
||||
"reviewNotesPlaceholder": "Optional notes for the requester",
|
||||
"approved": "Approved",
|
||||
@@ -787,32 +789,47 @@
|
||||
"action": "Action",
|
||||
"entity": "Entity",
|
||||
"entityId": "ID",
|
||||
"diff": "Details"
|
||||
"diff": "Details",
|
||||
"changes": "Changes"
|
||||
},
|
||||
"created": "Created",
|
||||
"removed": "Removed",
|
||||
"moreChanges": "more changes",
|
||||
"entity": {
|
||||
"meeting": "Meeting",
|
||||
"attendee": "Attendee",
|
||||
"request": "Request",
|
||||
"task": "Task",
|
||||
"font_settings": "Font settings"
|
||||
"font_settings": "Font settings",
|
||||
"pdf_archive": "PDF archive"
|
||||
},
|
||||
"action": {
|
||||
"create": "Create",
|
||||
"update": "Update",
|
||||
"delete": "Delete",
|
||||
"approve": "Approve",
|
||||
"approved": "Approved",
|
||||
"reject": "Reject",
|
||||
"submit": "Submit"
|
||||
"rejected": "Rejected",
|
||||
"needs_edit": "Needs edit",
|
||||
"withdraw": "Withdrawn",
|
||||
"submit": "Submit",
|
||||
"apply": "Apply",
|
||||
"duplicate": "Duplicate",
|
||||
"replace_attendees": "Update attendees",
|
||||
"done": "Done"
|
||||
}
|
||||
},
|
||||
"pdf": {
|
||||
"heading": "Print / Export",
|
||||
"intro": "This view uses the browser print dialog to produce a PDF of the day's schedule. Pick a date and click Print.",
|
||||
"print": "Print schedule",
|
||||
"print": "Print + archive",
|
||||
"printOnly": "Print only",
|
||||
"openSchedule": "Open schedule",
|
||||
"selectDate": "Schedule date",
|
||||
"archiveSnapshot": "Archive snapshot",
|
||||
"archived": "Snapshot archived",
|
||||
"archivedAndPrinted": "Snapshot archived — print dialog opened",
|
||||
"archivesHeading": "Archived snapshots",
|
||||
"noArchives": "No snapshots archived for this date yet."
|
||||
},
|
||||
|
||||
@@ -416,7 +416,7 @@ export default function ExecutiveMeetingsPage() {
|
||||
/>
|
||||
)}
|
||||
{section === "notifications" && me && (
|
||||
<NotificationsSection canView={me.canViewAudit} isRtl={isRtl} t={t} />
|
||||
<NotificationsSection canView={me.canRead} isRtl={isRtl} t={t} />
|
||||
)}
|
||||
{section === "audit" && me && (
|
||||
<AuditSection canView={me.canViewAudit} isRtl={isRtl} t={t} />
|
||||
@@ -1595,17 +1595,22 @@ function ApprovalsSection({
|
||||
});
|
||||
const requests = data?.requests ?? [];
|
||||
|
||||
async function review(id: number, action: "approve" | "reject") {
|
||||
async function review(
|
||||
id: number,
|
||||
status: "approved" | "rejected" | "needs_edit",
|
||||
) {
|
||||
try {
|
||||
await apiJson(`/api/executive-meetings/requests/${id}`, {
|
||||
method: "PATCH",
|
||||
body: { action, reviewNotes: notes[id] ?? "" },
|
||||
body: { status, reviewNotes: notes[id] ?? "" },
|
||||
});
|
||||
toast({
|
||||
title:
|
||||
action === "approve"
|
||||
status === "approved"
|
||||
? t("executiveMeetings.approvals.approved")
|
||||
: t("executiveMeetings.approvals.rejected"),
|
||||
: status === "rejected"
|
||||
? t("executiveMeetings.approvals.rejected")
|
||||
: t("executiveMeetings.approvals.needsEditDone"),
|
||||
});
|
||||
await Promise.all([
|
||||
qc.invalidateQueries({ queryKey: ["/api/executive-meetings/requests"] }),
|
||||
@@ -1675,10 +1680,18 @@ function ApprovalsSection({
|
||||
value={notes[r.id] ?? ""}
|
||||
onChange={(e) => setNotes({ ...notes, [r.id]: e.target.value })}
|
||||
/>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<div className="flex gap-2 justify-end flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => review(r.id, "reject")}
|
||||
onClick={() => review(r.id, "needs_edit")}
|
||||
className="gap-1"
|
||||
data-testid={`em-needs-edit-${r.id}`}
|
||||
>
|
||||
{t("executiveMeetings.approvals.needsEdit")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => review(r.id, "rejected")}
|
||||
className="gap-1"
|
||||
data-testid={`em-reject-${r.id}`}
|
||||
>
|
||||
@@ -1686,7 +1699,7 @@ function ApprovalsSection({
|
||||
{t("executiveMeetings.approvals.reject")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => review(r.id, "approve")}
|
||||
onClick={() => review(r.id, "approved")}
|
||||
className="bg-[#0B1E3F] gap-1"
|
||||
data-testid={`em-approve-${r.id}`}
|
||||
>
|
||||
@@ -1963,6 +1976,116 @@ function TasksSection({
|
||||
|
||||
// ============ AUDIT ============
|
||||
|
||||
// Compact "field: old → new" summary of an audit entry's old/new JSON values.
|
||||
// Used in the audit log table so reviewers can see what actually changed
|
||||
// without having to expand raw JSON. Falls back to JSON dumps for unknown
|
||||
// shapes so nothing is hidden.
|
||||
function AuditDiffSummary({
|
||||
oldValue,
|
||||
newValue,
|
||||
t,
|
||||
}: {
|
||||
oldValue: unknown;
|
||||
newValue: unknown;
|
||||
t: (k: string) => string;
|
||||
}) {
|
||||
const interesting = [
|
||||
"titleAr",
|
||||
"titleEn",
|
||||
"meetingDate",
|
||||
"dailyNumber",
|
||||
"startTime",
|
||||
"endTime",
|
||||
"location",
|
||||
"meetingUrl",
|
||||
"platform",
|
||||
"status",
|
||||
"isHighlighted",
|
||||
"notes",
|
||||
"assignedTo",
|
||||
"taskType",
|
||||
"dueAt",
|
||||
"reviewDecision",
|
||||
"reviewNotes",
|
||||
] as const;
|
||||
|
||||
const oldObj =
|
||||
oldValue && typeof oldValue === "object"
|
||||
? (oldValue as Record<string, unknown>)
|
||||
: null;
|
||||
const newObj =
|
||||
newValue && typeof newValue === "object"
|
||||
? (newValue as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
const fmt = (v: unknown): string => {
|
||||
if (v === null || v === undefined || v === "") return "—";
|
||||
if (typeof v === "string") return v.length > 40 ? `${v.slice(0, 40)}…` : v;
|
||||
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
||||
try {
|
||||
const s = JSON.stringify(v);
|
||||
return s.length > 40 ? `${s.slice(0, 40)}…` : s;
|
||||
} catch {
|
||||
return String(v);
|
||||
}
|
||||
};
|
||||
|
||||
if (!oldObj && !newObj) return <span className="text-gray-400">—</span>;
|
||||
|
||||
if (oldObj && newObj) {
|
||||
const diffs: { key: string; from: unknown; to: unknown }[] = [];
|
||||
for (const k of interesting) {
|
||||
const a = oldObj[k];
|
||||
const b = newObj[k];
|
||||
if (JSON.stringify(a) !== JSON.stringify(b)) {
|
||||
diffs.push({ key: k, from: a, to: b });
|
||||
}
|
||||
}
|
||||
if (diffs.length === 0) {
|
||||
return <span className="text-gray-400">—</span>;
|
||||
}
|
||||
return (
|
||||
<ul className="space-y-0.5">
|
||||
{diffs.slice(0, 6).map((d) => (
|
||||
<li key={d.key} className="leading-snug">
|
||||
<span className="font-semibold text-[#0B1E3F]">{d.key}</span>:{" "}
|
||||
<span className="text-red-700 line-through">{fmt(d.from)}</span>
|
||||
<span className="mx-1 text-gray-400">→</span>
|
||||
<span className="text-green-700">{fmt(d.to)}</span>
|
||||
</li>
|
||||
))}
|
||||
{diffs.length > 6 && (
|
||||
<li className="text-gray-500">
|
||||
+{diffs.length - 6} {t("executiveMeetings.audit.moreChanges")}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
// Pure create or pure delete — show a compact field summary
|
||||
const single = (newObj ?? oldObj) as Record<string, unknown>;
|
||||
const label = newObj
|
||||
? t("executiveMeetings.audit.created")
|
||||
: t("executiveMeetings.audit.removed");
|
||||
const items = interesting
|
||||
.filter((k) => single[k] !== undefined && single[k] !== null && single[k] !== "")
|
||||
.slice(0, 6);
|
||||
return (
|
||||
<div>
|
||||
<div className="font-semibold text-[#0B1E3F] mb-0.5">{label}</div>
|
||||
<ul className="space-y-0.5">
|
||||
{items.map((k) => (
|
||||
<li key={k} className="leading-snug">
|
||||
<span className="font-semibold">{k}</span>: {fmt(single[k])}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function AuditSection({
|
||||
canView,
|
||||
isRtl,
|
||||
@@ -2028,19 +2151,20 @@ function AuditSection({
|
||||
<th className="px-3 py-2 text-start w-28">{t("executiveMeetings.audit.col.action")}</th>
|
||||
<th className="px-3 py-2 text-start w-28">{t("executiveMeetings.audit.col.entity")}</th>
|
||||
<th className="px-3 py-2 text-start w-20">{t("executiveMeetings.audit.col.entityId")}</th>
|
||||
<th className="px-3 py-2 text-start">{t("executiveMeetings.audit.col.changes")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-8 text-center text-gray-500">
|
||||
<td colSpan={6} className="py-8 text-center text-gray-500">
|
||||
{t("common.loading")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!isLoading && entries.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-8 text-center text-gray-500">
|
||||
<td colSpan={6} className="py-8 text-center text-gray-500">
|
||||
{t("executiveMeetings.audit.empty")}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -2052,20 +2176,31 @@ function AuditSection({
|
||||
: e.actor.displayNameEn || e.actor.username
|
||||
: "—";
|
||||
return (
|
||||
<tr key={e.id} className="border-t border-gray-100">
|
||||
<td className="px-3 py-2 align-middle text-xs text-gray-600">
|
||||
<tr
|
||||
key={e.id}
|
||||
className="border-t border-gray-100 align-top"
|
||||
data-testid={`em-audit-row-${e.id}`}
|
||||
>
|
||||
<td className="px-3 py-2 text-xs text-gray-600 whitespace-nowrap">
|
||||
{new Date(e.performedAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-3 py-2 align-middle">{actor}</td>
|
||||
<td className="px-3 py-2 align-middle text-xs">
|
||||
<td className="px-3 py-2">{actor}</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
{t(`executiveMeetings.audit.action.${e.action}`, { defaultValue: e.action })}
|
||||
</td>
|
||||
<td className="px-3 py-2 align-middle text-xs">
|
||||
<td className="px-3 py-2 text-xs">
|
||||
{t(`executiveMeetings.audit.entity.${e.entityType}`, {
|
||||
defaultValue: e.entityType,
|
||||
})}
|
||||
</td>
|
||||
<td className="px-3 py-2 align-middle text-xs">{e.entityId ?? "—"}</td>
|
||||
<td className="px-3 py-2 text-xs">{e.entityId ?? "—"}</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
<AuditDiffSummary
|
||||
oldValue={e.oldValue}
|
||||
newValue={e.newValue}
|
||||
t={t}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -2208,6 +2343,30 @@ function PdfSection({
|
||||
}
|
||||
}
|
||||
|
||||
// Print + archive in one click: archive the snapshot first, then open the
|
||||
// print view in a new tab so the user can hit Ctrl+P / Save as PDF.
|
||||
async function archiveAndPrint() {
|
||||
if (!date) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiJson(`/api/executive-meetings/pdf-archives`, {
|
||||
method: "POST",
|
||||
body: { archiveDate: date },
|
||||
});
|
||||
await qc.invalidateQueries({
|
||||
queryKey: ["/api/executive-meetings/pdf-archives", date],
|
||||
});
|
||||
const url = `/executive-meetings/print?date=${date}&lang=${lang}`;
|
||||
window.open(url, "_blank", "noopener");
|
||||
toast({ title: t("executiveMeetings.pdf.archivedAndPrinted") });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast({ title: msg, variant: "destructive" });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const archives = archivesQuery.data?.archives ?? [];
|
||||
|
||||
return (
|
||||
@@ -2226,13 +2385,24 @@ function PdfSection({
|
||||
</FormRow>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={openPrint}
|
||||
onClick={archiveAndPrint}
|
||||
disabled={busy || !date}
|
||||
className="bg-[#0B1E3F] gap-1"
|
||||
data-testid="em-print"
|
||||
>
|
||||
<Printer className="w-4 h-4" />
|
||||
{t("executiveMeetings.pdf.print")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={openPrint}
|
||||
disabled={!date}
|
||||
variant="outline"
|
||||
className="gap-1"
|
||||
data-testid="em-print-only"
|
||||
>
|
||||
<Printer className="w-4 h-4" />
|
||||
{t("executiveMeetings.pdf.printOnly")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={archiveSnapshot}
|
||||
disabled={busy || !date}
|
||||
|
||||
Generated
+4
-1
@@ -61,7 +61,7 @@ catalogs:
|
||||
specifier: ^7.3.2
|
||||
version: 7.3.2
|
||||
zod:
|
||||
specifier: ^3.25.76
|
||||
specifier: 3.25.76
|
||||
version: 3.25.76
|
||||
|
||||
overrides:
|
||||
@@ -123,6 +123,9 @@ importers:
|
||||
socket.io:
|
||||
specifier: ^4.8.3
|
||||
version: 4.8.3
|
||||
zod:
|
||||
specifier: 'catalog:'
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@types/bcryptjs':
|
||||
specifier: ^3.0.0
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ catalog:
|
||||
tailwindcss: ^4.1.14
|
||||
tsx: ^4.21.0
|
||||
vite: ^7.3.2
|
||||
zod: ^3.25.76
|
||||
zod: 3.25.76
|
||||
|
||||
minimumReleaseAge: 1440
|
||||
|
||||
|
||||
Reference in New Issue
Block a user