Compare commits
63 Commits
b1589fe4e7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1000ed3a4f | |||
| aafd0fee4b | |||
| 96f9f8150e | |||
| dd2816a1a9 | |||
| 3ff1338c7e | |||
| e167b3ef45 | |||
| 97f0b9217d | |||
| bd50a93db5 | |||
| 049d01def5 | |||
| a8f4693fce | |||
| 2bea68c92d | |||
| 83f07c7a22 | |||
| 42d8ae13ad | |||
| 5bfe56f18c | |||
| a36adb5e9e | |||
| 22af2e8848 | |||
| 224e69f111 | |||
| 05586fc997 | |||
| 36c1dee00a | |||
| 1d7ae96b9c | |||
| e0d85ba12c | |||
| f89ef64315 | |||
| c676f51f13 | |||
| 74327027c3 | |||
| 50030ea29f | |||
| 637f4410b2 | |||
| ec7b1b7af2 | |||
| 159a044c24 | |||
| 2f84bd2c6f | |||
| 874d49c439 | |||
| 7ee4c0858a | |||
| 7f81eb01b2 | |||
| bb3ac7cc1c | |||
| 34f969178e | |||
| 92de2e374e | |||
| f189399fbf | |||
| 487304344d | |||
| 9821f7af1c | |||
| 348db18bcd | |||
| 3c1b3107e3 | |||
| 0c35fbe395 | |||
| 6f74fa1045 | |||
| 776fd8460e | |||
| b177f653e6 | |||
| bc2b648cc4 | |||
| 7bfb4afd1a | |||
| 2e6f02fe83 | |||
| 7b56f0fe18 | |||
| 7b4a60daf5 | |||
| 8d9bc7951e | |||
| 58d7ca500a | |||
| 2cc955ed65 | |||
| cd7e4cfad9 | |||
| 41792d8493 | |||
| 3ceabb85bc | |||
| 1096217420 | |||
| b4fa097703 | |||
| b448437bef | |||
| 0bfad8a3a5 | |||
| 3a2e0d156b | |||
| 991ddebe90 | |||
| 6d60994078 | |||
| 0413fec3f7 |
@@ -20,6 +20,7 @@
|
||||
"@workspace/api-zod": "workspace:*",
|
||||
"@workspace/db": "workspace:*",
|
||||
"arabic-persian-reshaper": "1.0.1",
|
||||
"archiver": "^8.0.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"bidi-js": "^1.0.3",
|
||||
"connect-pg-simple": "^10.0.0",
|
||||
@@ -41,6 +42,7 @@
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/archiver": "^8.0.0",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/connect-pg-simple": "^7.0.3",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
|
||||
@@ -23,6 +23,15 @@ const httpServer = createServer(app);
|
||||
|
||||
export const io = new SocketIOServer(httpServer, {
|
||||
path: "/api/socket.io",
|
||||
// #632: Detect dead connections faster. Defaults are 25s/20s which
|
||||
// means a silently-dropped WebSocket (Tailscale NAT timeout, iPad
|
||||
// PWA suspension, proxy hiccup) takes up to ~45s before the client
|
||||
// notices and reconnects — long enough that recipients miss the
|
||||
// realtime order push entirely. Tightening to 10s/5s puts the
|
||||
// detection cycle at ~15s, well inside the "is this app even
|
||||
// working?" window users complain about.
|
||||
pingInterval: 10000,
|
||||
pingTimeout: 5000,
|
||||
cors: {
|
||||
origin: process.env.ALLOWED_ORIGINS
|
||||
? process.env.ALLOWED_ORIGINS.split(",").map((o) => o.trim())
|
||||
@@ -54,6 +63,15 @@ io.on("connection", (socket) => {
|
||||
|
||||
socket.join(`user:${userId}`);
|
||||
|
||||
// #632: Client foreground-recovery probe. The web client emits this
|
||||
// on `visibilitychange` to verify the WebSocket isn't half-open
|
||||
// (silently dead behind Tailscale/NAT) — we ack immediately so a
|
||||
// genuinely-alive connection completes the round-trip, and a dead
|
||||
// one times out on the client side and force-cycles the transport.
|
||||
socket.on("client_health_probe", (ack?: () => void) => {
|
||||
if (typeof ack === "function") ack();
|
||||
});
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
logger.info({ socketId: socket.id }, "Socket disconnected");
|
||||
});
|
||||
|
||||
@@ -222,7 +222,7 @@ export async function broadcastExecutiveMeetingNotifications(
|
||||
type: "executive_meeting",
|
||||
relatedId: meetingId,
|
||||
tag: meetingId ? `meeting-${meetingId}-${notificationType}` : `meeting-${notificationType}`,
|
||||
url: "/meetings",
|
||||
url: meetingId ? `/meetings?meeting=${meetingId}` : "/meetings",
|
||||
});
|
||||
}
|
||||
io.emit("executive_meeting_notifications_changed", {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq, inArray, ne, sql } from "drizzle-orm";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { db } from "@workspace/db";
|
||||
import {
|
||||
executiveMeetingAlertStateTable,
|
||||
@@ -43,6 +43,16 @@ function stripHtmlToPlainText(input: string | null | undefined): string {
|
||||
return decoded.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
// Arabic minute pluralisation for the "starts after N minutes" reminder
|
||||
// body. Arabic uses three forms for small counts: 1 -> دقيقة, 2 -> دقيقتين
|
||||
// (dual), 3+ -> دقائق. The scheduler only fires for deltas of 1-5 min, but
|
||||
// the helper stays correct for the dual/plural boundary regardless.
|
||||
function minutesAfterAr(n: number): string {
|
||||
if (n <= 1) return "بعد دقيقة";
|
||||
if (n === 2) return "بعد دقيقتين";
|
||||
return `بعد ${n} دقائق`;
|
||||
}
|
||||
|
||||
function dateKey(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||
}
|
||||
@@ -92,8 +102,13 @@ async function tickOnce(): Promise<void> {
|
||||
.where(
|
||||
and(
|
||||
inArray(executiveMeetingsTable.meetingDate, [todayKey, tomorrowKey]),
|
||||
ne(executiveMeetingsTable.status, "cancelled"),
|
||||
ne(executiveMeetingsTable.status, "completed"),
|
||||
// #626: only ring for meetings whose status is still the
|
||||
// default "scheduled". Previously we used a denylist
|
||||
// (ne cancelled + ne completed), which let postponed /
|
||||
// rescheduled / in_progress and any future status sneak past
|
||||
// and trigger a false 5-minute push reminder. Whitelist is
|
||||
// safer: any status other than "scheduled" is silenced.
|
||||
eq(executiveMeetingsTable.status, "scheduled"),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -156,7 +171,7 @@ async function tickOnce(): Promise<void> {
|
||||
stripHtmlToPlainText(meeting.titleEn) ||
|
||||
stripHtmlToPlainText(meeting.titleAr) ||
|
||||
"Meeting";
|
||||
const bodyAr = `${subjectAr} يبدأ خلال ${deltaMin} دقيقة`;
|
||||
const bodyAr = `${subjectAr} يبدأ ${minutesAfterAr(deltaMin)}`;
|
||||
const bodyEn = `${subjectEn} starts in ${deltaMin} min`;
|
||||
|
||||
for (const { userId } of claimed) {
|
||||
@@ -170,7 +185,7 @@ async function tickOnce(): Promise<void> {
|
||||
bodyAr,
|
||||
bodyEn,
|
||||
tag: `em-reminder-${meeting.id}`,
|
||||
url: "/meetings",
|
||||
url: `/meetings?meeting=${meeting.id}`,
|
||||
type: "executive_meeting",
|
||||
relatedId: meeting.id,
|
||||
},
|
||||
|
||||
@@ -5,10 +5,11 @@ import {
|
||||
servicesTable,
|
||||
executiveMeetingFontSettingsTable,
|
||||
executiveMeetingPdfArchivesTable,
|
||||
protocolPhotosTable,
|
||||
rolesTable,
|
||||
} from "@workspace/db";
|
||||
import { eq, inArray, sql } from "drizzle-orm";
|
||||
import { getEffectiveRoleIds } from "../middlewares/auth";
|
||||
import { getEffectiveRoleIds, userHasPermission } from "../middlewares/auth";
|
||||
import { getVisibleAppsForUser } from "./appsVisibility";
|
||||
|
||||
// Roles that gate read access to the Executive Meetings module.
|
||||
@@ -152,6 +153,18 @@ export async function canUserReadObjectPath(
|
||||
const rows = (meeting as unknown as { rows?: unknown[] }).rows ?? [];
|
||||
if (rows.length > 0) return hasExecutiveAccess;
|
||||
|
||||
// 7. Protocol visit photos — gated by the protocol read permission
|
||||
// (`protocol.access`), the same scoped permission that makes the
|
||||
// Protocol tile visible. Never gated by role name (module convention).
|
||||
const photo = await db
|
||||
.select({ id: protocolPhotosTable.id })
|
||||
.from(protocolPhotosTable)
|
||||
.where(eq(protocolPhotosTable.objectPath, objectPath))
|
||||
.limit(1);
|
||||
if (photo.length > 0) {
|
||||
return isAdmin || (await userHasPermission(userId, "protocol.access"));
|
||||
}
|
||||
|
||||
// Orphan path — no entity references it. Treat as not-found for
|
||||
// every role (including admin). This is what stops object
|
||||
// enumeration: even with admin credentials, you cannot probe
|
||||
|
||||
@@ -25,6 +25,30 @@ export class ObjectNotFoundError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Map a stored object's content type to a sensible file extension so
|
||||
// downloads land with a usable name (stored paths are extension-less UUIDs).
|
||||
const CONTENT_TYPE_EXTENSIONS: Record<string, string> = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/jpg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/gif": "gif",
|
||||
"image/webp": "webp",
|
||||
"image/heic": "heic",
|
||||
"image/heif": "heif",
|
||||
"image/bmp": "bmp",
|
||||
"image/tiff": "tiff",
|
||||
"image/svg+xml": "svg",
|
||||
"application/pdf": "pdf",
|
||||
};
|
||||
|
||||
export function extensionForContentType(
|
||||
contentType: string | undefined,
|
||||
): string {
|
||||
if (!contentType) return "bin";
|
||||
const base = contentType.split(";")[0].trim().toLowerCase();
|
||||
return CONTENT_TYPE_EXTENSIONS[base] ?? "bin";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Driver interface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -253,3 +253,36 @@ export async function requireExecutiveAccess(
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate any read access to the Public Relations & Protocol module. Guarded by
|
||||
* the scoped `protocol.access` permission — the same permission that gates the
|
||||
* home-screen tile (via app_permissions) — so backend usability aligns exactly
|
||||
* with the visible permission grant. Finer-grained request / mutate / approve /
|
||||
* rooms / audit capabilities are layered on top via scoped permissions in the
|
||||
* routes file.
|
||||
*/
|
||||
export async function requireProtocolAccess(
|
||||
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 ok = await userHasPermission(req.session.userId, "protocol.access");
|
||||
if (!ok) {
|
||||
res.status(403).json({ error: "Forbidden", code: "forbidden" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,11 @@ import {
|
||||
groupsTable,
|
||||
userGroupsTable,
|
||||
auditLogsTable,
|
||||
pushSubscriptionsTable,
|
||||
} from "@workspace/db";
|
||||
import { requireAuth, requireAdmin, getUserRoles } from "../middlewares/auth";
|
||||
import { saveSession, destroySession } from "../lib/session";
|
||||
import { logger } from "../lib/logger";
|
||||
import {
|
||||
loginLimiters,
|
||||
registerLimiters,
|
||||
@@ -354,6 +356,36 @@ router.post(
|
||||
);
|
||||
|
||||
router.post("/auth/logout", async (req, res): Promise<void> => {
|
||||
// #626: drop every Web Push subscription this user owns before
|
||||
// tearing down the session. Otherwise the scheduler keeps firing
|
||||
// pushes to the iPad after sign-out (the rows live independently of
|
||||
// the session table, keyed by user_id). The client also tries to
|
||||
// unsubscribe its own browser endpoint, but that fails closed if the
|
||||
// SW or network hiccups — this server-side wipe is the authoritative
|
||||
// stop. Pruning every endpoint (not just the caller's) is the
|
||||
// correct behaviour for a single-user-per-account product: signing
|
||||
// out on any device implies "I don't want notifications anywhere
|
||||
// until I sign back in".
|
||||
const userId = req.session.userId;
|
||||
if (typeof userId === "number" && Number.isInteger(userId) && userId > 0) {
|
||||
try {
|
||||
await db
|
||||
.delete(pushSubscriptionsTable)
|
||||
.where(eq(pushSubscriptionsTable.userId, userId));
|
||||
} catch (err) {
|
||||
// Best-effort: a failed delete must not block sign-out. Log so
|
||||
// an operator investigating "I logged out but still got a push"
|
||||
// can correlate the leaked row to a real DB failure instead of
|
||||
// chasing a silent regression. The scheduler also prunes
|
||||
// 404/410 endpoints on send, so a leaked row eventually
|
||||
// self-cleans the next time it's used (worst case: one stale
|
||||
// ring until the OS-level subscription expires).
|
||||
logger.warn(
|
||||
{ err, userId },
|
||||
"Failed to delete push_subscriptions on logout — endpoint may continue receiving pushes until next 404/410 prune",
|
||||
);
|
||||
}
|
||||
}
|
||||
await destroySession(req);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
@@ -231,6 +231,12 @@ const meetingBaseFields = {
|
||||
.transform((v) => (v ? 1 : 0))
|
||||
.optional(),
|
||||
notes: z.string().trim().max(5000).nullable().optional(),
|
||||
// #635: external-meeting flag. Coerce truthy/falsy → boolean so older
|
||||
// clients can keep sending 0/1 without surprises.
|
||||
isExternal: z
|
||||
.union([z.boolean(), z.number()])
|
||||
.transform((v) => Boolean(v))
|
||||
.optional(),
|
||||
} as const;
|
||||
|
||||
const meetingCreateSchema = z
|
||||
@@ -282,6 +288,7 @@ const meetingPatchSchema = z
|
||||
status: meetingBaseFields.status,
|
||||
isHighlighted: meetingBaseFields.isHighlighted,
|
||||
notes: meetingBaseFields.notes,
|
||||
isExternal: meetingBaseFields.isExternal,
|
||||
attendees: z.array(attendeeSchema).optional(),
|
||||
merge: meetingMergeSchema.optional(),
|
||||
// Row-highlight colour (#288). null = clear back to default (no tint).
|
||||
@@ -669,6 +676,12 @@ router.post(
|
||||
status: data.status ?? "scheduled",
|
||||
isHighlighted: data.isHighlighted ?? 0,
|
||||
notes: stripTagsToPlainTextOrNull(data.notes),
|
||||
// #635: persist the external-meeting flag; default false.
|
||||
// When true, force rowColor='red' so the schedule always
|
||||
// renders the row in the agreed external-meeting tint even
|
||||
// if the client forgot to pass it.
|
||||
isExternal: Boolean(data.isExternal ?? false),
|
||||
rowColor: data.isExternal ? "red" : (data as { rowColor?: string }).rowColor ?? null,
|
||||
createdBy: userId,
|
||||
updatedBy: userId,
|
||||
};
|
||||
@@ -843,6 +856,22 @@ router.patch(
|
||||
if (data.rowColor !== undefined) {
|
||||
updateValues.rowColor = data.rowColor;
|
||||
}
|
||||
// #635: persist isExternal. When switching to external, also
|
||||
// force rowColor='red' (overrides any rowColor in the same
|
||||
// PATCH) so the red tint always tracks the flag.
|
||||
if (data.isExternal !== undefined) {
|
||||
updateValues.isExternal = Boolean(data.isExternal);
|
||||
if (data.isExternal) updateValues.rowColor = "red";
|
||||
}
|
||||
// #635: belt-and-suspenders — if the row is (or is becoming)
|
||||
// external and a non-red rowColor would otherwise be persisted
|
||||
// by this PATCH, force red. Prevents PDF/export paths that read
|
||||
// the raw rowColor from showing a non-red tint for externals.
|
||||
const willBeExternal =
|
||||
data.isExternal !== undefined ? Boolean(data.isExternal) : existing.isExternal;
|
||||
if (willBeExternal && updateValues.rowColor !== undefined && updateValues.rowColor !== "red") {
|
||||
updateValues.rowColor = "red";
|
||||
}
|
||||
|
||||
if (Object.keys(updateValues).length > 1) {
|
||||
await tx
|
||||
@@ -1126,6 +1155,7 @@ async function computeCascadeShift(
|
||||
startTime: executiveMeetingsTable.startTime,
|
||||
endTime: executiveMeetingsTable.endTime,
|
||||
status: executiveMeetingsTable.status,
|
||||
isExternal: executiveMeetingsTable.isExternal,
|
||||
})
|
||||
.from(executiveMeetingsTable)
|
||||
.where(eq(executiveMeetingsTable.meetingDate, date));
|
||||
@@ -1138,6 +1168,10 @@ async function computeCascadeShift(
|
||||
const sorted = candidates
|
||||
.filter((c) => c.id !== primaryId)
|
||||
.filter((c) => c.status !== "cancelled" && c.status !== "completed")
|
||||
// #635: external meetings are excluded from auto-postpone cascades
|
||||
// by design — they're scheduled with the other party and can't be
|
||||
// unilaterally shifted.
|
||||
.filter((c) => !c.isExternal)
|
||||
.map((c) => ({
|
||||
...c,
|
||||
_startMin: parseTimeToMinutes(c.startTime),
|
||||
@@ -1573,6 +1607,18 @@ router.post(
|
||||
if (!locked) {
|
||||
return { ok: false, status: 404, error: "Meeting not found", code: "not_found" };
|
||||
}
|
||||
// #635: External meetings can never be auto-postponed — they're
|
||||
// scheduled with an outside party and shifting them unilaterally
|
||||
// would put the schedule out of sync. The UI also disables the
|
||||
// Postpone button, so this is a belt-and-suspenders 409.
|
||||
if (locked.isExternal) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 409,
|
||||
error: "External meetings cannot be auto-postponed",
|
||||
code: "external_meeting_no_auto_postpone",
|
||||
};
|
||||
}
|
||||
// #283: Optimistic-locking guard. The client always sends the
|
||||
// `updatedAt` it last observed; if the row has been modified
|
||||
// since, refuse to mutate and return the current state + the
|
||||
|
||||
@@ -16,6 +16,7 @@ import groupsRouter from "./groups";
|
||||
import rolesRouter from "./roles";
|
||||
import auditRouter from "./audit";
|
||||
import executiveMeetingsRouter from "./executive-meetings";
|
||||
import protocolRouter from "./protocol";
|
||||
import pushRouter from "./push";
|
||||
import systemRouter from "./system";
|
||||
|
||||
@@ -38,6 +39,7 @@ router.use(groupsRouter);
|
||||
router.use(rolesRouter);
|
||||
router.use(auditRouter);
|
||||
router.use(executiveMeetingsRouter);
|
||||
router.use(protocolRouter);
|
||||
router.use(pushRouter);
|
||||
router.use(systemRouter);
|
||||
|
||||
|
||||
@@ -1130,6 +1130,8 @@ router.post("/notes/:id/send", requireAuth, async (req, res): Promise<void> => {
|
||||
bodyAr: `${senderNameAr} أرسل لك ملاحظة`,
|
||||
bodyEn: `${senderName} sent you a note`,
|
||||
type: "note",
|
||||
relatedType: "note",
|
||||
relatedId: note.id,
|
||||
})
|
||||
.returning();
|
||||
await emitToUser(r.recipientUserId, "notification_created", { ...notif, type: "note" });
|
||||
@@ -1143,7 +1145,7 @@ router.post("/notes/:id/send", requireAuth, async (req, res): Promise<void> => {
|
||||
type: "note",
|
||||
relatedId: note.id,
|
||||
tag: `note-${note.id}`,
|
||||
url: "/notes",
|
||||
url: `/notes?thread=${note.id}`,
|
||||
});
|
||||
await emitToUser(r.recipientUserId, "note_received", {
|
||||
noteId: note.id,
|
||||
@@ -1488,6 +1490,8 @@ router.post("/notes/:id/reply", requireAuth, async (req, res): Promise<void> =>
|
||||
bodyAr: `${replierNameAr} رد على ملاحظة`,
|
||||
bodyEn: `${replierName} replied on a note`,
|
||||
type: "note",
|
||||
relatedType: "note",
|
||||
relatedId: id.id,
|
||||
})
|
||||
.returning();
|
||||
await emitToUser(otherPartyUserId, "notification_created", { ...notif, type: "note" });
|
||||
@@ -1501,7 +1505,7 @@ router.post("/notes/:id/reply", requireAuth, async (req, res): Promise<void> =>
|
||||
type: "note",
|
||||
relatedId: id.id,
|
||||
tag: `note-reply-${reply.id}`,
|
||||
url: "/notes",
|
||||
url: `/notes?thread=${id.id}`,
|
||||
});
|
||||
// Enrich the realtime payload so the recipient's client can render the
|
||||
// floating reply card without an extra round-trip. We truncate the body
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -117,9 +117,19 @@ async function notifyUser(
|
||||
bodyEn: string,
|
||||
orderId: number,
|
||||
actorId?: number,
|
||||
// Which order surface the deep-link should open: the recipient's own
|
||||
// "My orders" list (status/cancel updates) or the "Incoming orders"
|
||||
// queue (a brand-new order to claim). Drives both the stored
|
||||
// notification's relatedType and the Web Push deep-link URL so a tap
|
||||
// lands on the right page with the order highlighted.
|
||||
target: "mine" | "incoming" = "mine",
|
||||
) {
|
||||
// Never notify users about actions they themselves initiated.
|
||||
if (actorId !== undefined && actorId === userId) return;
|
||||
const relatedType = target === "incoming" ? "incoming_order" : "order";
|
||||
const deepLinkUrl = `${
|
||||
target === "incoming" ? "/orders/incoming" : "/my-orders"
|
||||
}?order=${orderId}`;
|
||||
const [notification] = await db
|
||||
.insert(notificationsTable)
|
||||
.values({
|
||||
@@ -130,22 +140,34 @@ async function notifyUser(
|
||||
bodyEn,
|
||||
type: "order",
|
||||
relatedId: orderId,
|
||||
relatedType: "order",
|
||||
relatedType,
|
||||
})
|
||||
.returning();
|
||||
await emitToUser(userId, "notification_created", notification);
|
||||
void sendPushToUser(userId, {
|
||||
title: titleAr,
|
||||
body: bodyAr,
|
||||
titleAr,
|
||||
titleEn,
|
||||
bodyAr,
|
||||
bodyEn,
|
||||
type: "order",
|
||||
relatedId: orderId,
|
||||
tag: `order-${orderId}`,
|
||||
url: "/my-orders",
|
||||
});
|
||||
// #628: pass `ignoreConnected: true` so a stale/ghost Socket.IO
|
||||
// session on a forgotten laptop tab no longer suppresses the Web
|
||||
// Push to the receiver's iPad. Orders are time-critical exactly
|
||||
// like the 5-min meeting reminder — better to ring twice on the
|
||||
// foreground device than to ring zero times on the device the user
|
||||
// is actually carrying. The in-app chime path stays unchanged for
|
||||
// truly-foregrounded sockets; this just removes the silent-skip
|
||||
// when the "connected" tab is in fact idle/closed.
|
||||
void sendPushToUser(
|
||||
userId,
|
||||
{
|
||||
title: titleAr,
|
||||
body: bodyAr,
|
||||
titleAr,
|
||||
titleEn,
|
||||
bodyAr,
|
||||
bodyEn,
|
||||
type: "order",
|
||||
relatedId: orderId,
|
||||
tag: `order-${orderId}`,
|
||||
url: deepLinkUrl,
|
||||
},
|
||||
{ ignoreConnected: true },
|
||||
);
|
||||
}
|
||||
|
||||
async function broadcastIncomingChanged(receiverIds: number[]) {
|
||||
@@ -197,6 +219,7 @@ router.post("/orders", requireAuth, async (req, res): Promise<void> => {
|
||||
service.nameEn,
|
||||
order.id,
|
||||
userId,
|
||||
"incoming",
|
||||
);
|
||||
}
|
||||
await broadcastIncomingChanged(receivers);
|
||||
|
||||
@@ -4,7 +4,11 @@ import {
|
||||
RequestUploadUrlBody,
|
||||
RequestUploadUrlResponse,
|
||||
} from "@workspace/api-zod";
|
||||
import { ObjectStorageService, ObjectNotFoundError } from "../lib/objectStorage";
|
||||
import {
|
||||
ObjectStorageService,
|
||||
ObjectNotFoundError,
|
||||
extensionForContentType,
|
||||
} from "../lib/objectStorage";
|
||||
import { canUserReadObjectPath } from "../lib/objectAuthz";
|
||||
import { requireAuth } from "../middlewares/auth";
|
||||
|
||||
@@ -109,6 +113,34 @@ router.get("/storage/objects/*path", requireAuth, async (req: Request, res: Resp
|
||||
res.status(response.status);
|
||||
response.headers.forEach((value, key) => res.setHeader(key, value));
|
||||
|
||||
// Opt-in force-download: `?download=1` serves the object as an
|
||||
// attachment (Content-Disposition) so browsers save it instead of
|
||||
// rendering inline. An optional `name` base is combined with the
|
||||
// extension derived from the object's content type.
|
||||
if (req.query.download !== undefined) {
|
||||
const contentType = response.headers.get("content-type") ?? undefined;
|
||||
const ext = extensionForContentType(contentType);
|
||||
const baseRaw =
|
||||
typeof req.query.name === "string" && req.query.name.trim()
|
||||
? req.query.name
|
||||
: (wildcardPath.split("/").pop() ?? "download");
|
||||
// Strip characters that would break the header or the filesystem.
|
||||
const safeBase = baseRaw
|
||||
.replace(/[\r\n"\\/]/g, "")
|
||||
.replace(/[\u0000-\u001f]/g, "")
|
||||
.trim()
|
||||
.slice(0, 180) || "download";
|
||||
const filename = `${safeBase}.${ext}`;
|
||||
const asciiFallback =
|
||||
filename.replace(/[^\x20-\x7e]/g, "_") || `download.${ext}`;
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodeURIComponent(
|
||||
filename,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (response.body) {
|
||||
const nodeStream = Readable.fromWeb(response.body as ReadableStream<Uint8Array>);
|
||||
nodeStream.pipe(res);
|
||||
|
||||
@@ -18,7 +18,12 @@ function scopedUrl(relative) {
|
||||
// Strip a leading slash from `relative` so URL() treats it as a
|
||||
// path under scope rather than origin-relative.
|
||||
const cleaned = String(relative || "").replace(/^\/+/, "");
|
||||
return new URL(cleaned, self.registration.scope).pathname;
|
||||
const resolved = new URL(cleaned, self.registration.scope);
|
||||
// Preserve the query string and hash so notification deep-links
|
||||
// (e.g. "/meetings?meeting=12" or "/notes?thread=7") survive the click
|
||||
// handler — taking only .pathname would drop them and land the user on
|
||||
// the generic page instead of the specific item.
|
||||
return resolved.pathname + resolved.search + resolved.hash;
|
||||
}
|
||||
|
||||
self.addEventListener("install", () => {
|
||||
|
||||
@@ -8,7 +8,10 @@ import { IncomingNotePopup } from "@/components/notes/incoming-note-popup";
|
||||
import { useNotificationsSocket } from "@/hooks/use-notifications-socket";
|
||||
import { useAudioUnlock } from "@/hooks/use-audio-unlock";
|
||||
import { useAutoplayHint } from "@/hooks/use-autoplay-hint";
|
||||
import { useScrollFocusedDialogField } from "@/hooks/use-scroll-focused-dialog-field";
|
||||
// #625-followup: the global keyboard hooks (useScrollFocusedDialogField,
|
||||
// useVisualViewportVars) were removed because they fired on touch-screen
|
||||
// laptops too and broke desktop dialog layout. A more targeted approach
|
||||
// will replace them.
|
||||
import { UpcomingMeetingAlert } from "@/components/executive-meetings/upcoming-meeting-alert";
|
||||
import { PushEnablePrompt } from "@/components/push-enable-prompt";
|
||||
import NotFound from "@/pages/not-found";
|
||||
@@ -24,6 +27,8 @@ import NotesPage from "@/pages/notes";
|
||||
import MyOrdersPage from "@/pages/my-orders";
|
||||
import OrdersIncomingPage from "@/pages/orders-incoming";
|
||||
import ExecutiveMeetingsPage from "@/pages/executive-meetings";
|
||||
import ProtocolPage from "@/pages/protocol";
|
||||
import ProtocolRequestPage from "@/pages/protocol-request";
|
||||
import EmbeddedAppPage from "@/pages/embedded-app";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -47,7 +52,6 @@ function NotificationsSocketBridge() {
|
||||
useNotificationsSocket();
|
||||
useAudioUnlock();
|
||||
useAutoplayHint();
|
||||
useScrollFocusedDialogField();
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -71,6 +75,8 @@ function Router() {
|
||||
<Route path="/my-orders" component={MyOrdersPage} />
|
||||
<Route path="/orders/incoming" component={OrdersIncomingPage} />
|
||||
<Route path="/meetings" component={ExecutiveMeetingsPage} />
|
||||
<Route path="/protocol" component={ProtocolPage} />
|
||||
<Route path="/protocol/:tab" component={ProtocolPage} />
|
||||
{/* Back-compat redirect: the page used to live at
|
||||
/executive-meetings. Stored PDFs, email links, and bookmarks
|
||||
still point there, so catch the old path (and any deep
|
||||
@@ -92,7 +98,16 @@ function App() {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<WouterRouter base={import.meta.env.BASE_URL.replace(/\/$/, "")}>
|
||||
<Router />
|
||||
{/* The public room booking request form must render for logged-OUT
|
||||
guests, so it lives OUTSIDE <Router> (and thus outside
|
||||
AuthProvider, which redirects unauthenticated users to /login).
|
||||
The catch-all falls through to the authenticated app. */}
|
||||
<Switch>
|
||||
<Route path="/protocol/request" component={ProtocolRequestPage} />
|
||||
<Route>
|
||||
<Router />
|
||||
</Route>
|
||||
</Switch>
|
||||
</WouterRouter>
|
||||
<Toaster />
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import * as React from "react";
|
||||
import { CalendarClock } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
const AR_LATN = "ar-u-nu-latn";
|
||||
|
||||
function pad(n: number): string {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
// Value contract: local wall-clock string "YYYY-MM-DDTHH:mm" (same shape a
|
||||
// native datetime-local input produces) or "" when nothing is selected.
|
||||
function parseValue(value: string): Date | undefined {
|
||||
if (!value) return undefined;
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
function toValue(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(
|
||||
d.getHours(),
|
||||
)}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function to24(hour12: number, period: "am" | "pm"): number {
|
||||
if (period === "am") return hour12 === 12 ? 0 : hour12;
|
||||
return hour12 === 12 ? 12 : hour12 + 12;
|
||||
}
|
||||
|
||||
const HOURS_12 = Array.from({ length: 12 }, (_, i) => i + 1);
|
||||
const MINUTES = Array.from({ length: 12 }, (_, i) => i * 5);
|
||||
|
||||
function TimeColumn({
|
||||
values,
|
||||
active,
|
||||
onSelect,
|
||||
format,
|
||||
ariaLabel,
|
||||
}: {
|
||||
values: number[];
|
||||
active: number;
|
||||
onSelect: (v: number) => void;
|
||||
format: (v: number) => string;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
const activeRef = React.useRef<HTMLButtonElement>(null);
|
||||
React.useEffect(() => {
|
||||
activeRef.current?.scrollIntoView({ block: "center" });
|
||||
}, [active]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label={ariaLabel}
|
||||
className="h-40 w-14 overflow-y-auto rounded-md border p-1"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
{values.map((v) => {
|
||||
const isActive = v === active;
|
||||
return (
|
||||
<Button
|
||||
key={v}
|
||||
ref={isActive ? activeRef : undefined}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={isActive ? "default" : "ghost"}
|
||||
className="h-8 w-full shrink-0"
|
||||
onClick={() => onSelect(v)}
|
||||
>
|
||||
{format(v)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DateTimePicker({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "اختر التاريخ والوقت",
|
||||
disabled,
|
||||
minDate,
|
||||
}: {
|
||||
id?: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
minDate?: Date;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const selected = parseValue(value);
|
||||
|
||||
const hour12 = selected ? selected.getHours() % 12 || 12 : 9;
|
||||
const minute = selected ? selected.getMinutes() : 0;
|
||||
const period: "am" | "pm" = selected
|
||||
? selected.getHours() < 12
|
||||
? "am"
|
||||
: "pm"
|
||||
: "am";
|
||||
|
||||
const baseDate = (): Date => {
|
||||
if (selected) return new Date(selected);
|
||||
const d = minDate ? new Date(minDate) : new Date();
|
||||
d.setHours(9, 0, 0, 0);
|
||||
return d;
|
||||
};
|
||||
|
||||
const commit = (next: Date) => onChange(toValue(next));
|
||||
|
||||
const handleDay = (day: Date | undefined) => {
|
||||
if (!day) return;
|
||||
const base = baseDate();
|
||||
const next = new Date(day);
|
||||
next.setHours(base.getHours(), base.getMinutes(), 0, 0);
|
||||
commit(next);
|
||||
};
|
||||
|
||||
const handleHour = (h: number) => {
|
||||
const next = baseDate();
|
||||
next.setHours(to24(h, period), next.getMinutes(), 0, 0);
|
||||
commit(next);
|
||||
};
|
||||
|
||||
const handleMinute = (m: number) => {
|
||||
const next = baseDate();
|
||||
next.setMinutes(m, 0, 0);
|
||||
commit(next);
|
||||
};
|
||||
|
||||
const handlePeriod = (p: "am" | "pm") => {
|
||||
const next = baseDate();
|
||||
next.setHours(to24(hour12, p), next.getMinutes(), 0, 0);
|
||||
commit(next);
|
||||
};
|
||||
|
||||
const label = selected
|
||||
? new Intl.DateTimeFormat(AR_LATN, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(selected)
|
||||
: placeholder;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full justify-between font-normal",
|
||||
!selected && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{label}</span>
|
||||
<CalendarClock className="size-4 shrink-0 opacity-60" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selected}
|
||||
onSelect={handleDay}
|
||||
defaultMonth={selected ?? minDate ?? new Date()}
|
||||
disabled={minDate ? { before: minDate } : undefined}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="border-t p-3">
|
||||
<div className="mb-2 text-center text-xs font-medium text-muted-foreground">
|
||||
الوقت
|
||||
</div>
|
||||
<div className="flex items-stretch justify-center gap-2">
|
||||
<TimeColumn
|
||||
values={HOURS_12}
|
||||
active={hour12}
|
||||
onSelect={handleHour}
|
||||
format={pad}
|
||||
ariaLabel="الساعة"
|
||||
/>
|
||||
<div className="flex items-center text-muted-foreground">:</div>
|
||||
<TimeColumn
|
||||
values={MINUTES}
|
||||
active={minute}
|
||||
onSelect={handleMinute}
|
||||
format={pad}
|
||||
ariaLabel="الدقيقة"
|
||||
/>
|
||||
<div className="flex flex-col justify-center gap-1">
|
||||
{(["am", "pm"] as const).map((p) => (
|
||||
<Button
|
||||
key={p}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={period === p ? "default" : "outline"}
|
||||
className="h-8 w-12"
|
||||
onClick={() => handlePeriod(p)}
|
||||
>
|
||||
{p === "am" ? "ص" : "م"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end border-t p-2">
|
||||
<Button type="button" size="sm" onClick={() => setOpen(false)}>
|
||||
تم
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -80,6 +80,10 @@ type Meeting = {
|
||||
// when another user mutated this meeting after it was loaded. Server
|
||||
// sets it on every update; client echoes it back as expectedUpdatedAt.
|
||||
updatedAt: string;
|
||||
// #635: external meeting flag — the alert "Postpone" button is
|
||||
// disabled when true (the server also rejects with
|
||||
// `external_meeting_no_auto_postpone`).
|
||||
isExternal?: boolean;
|
||||
};
|
||||
type DayResponse = { date: string; meetings: Meeting[] };
|
||||
type AlertState = {
|
||||
@@ -698,7 +702,14 @@ export function UpcomingMeetingAlert() {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setPostponeOpen(true)}
|
||||
disabled={savingAction !== null}
|
||||
// #635: external meetings can't be auto-postponed (the
|
||||
// server also rejects with external_meeting_no_auto_postpone).
|
||||
disabled={savingAction !== null || meeting.isExternal === true}
|
||||
title={
|
||||
meeting.isExternal
|
||||
? t("executiveMeetings.alert.externalCannotPostpone")
|
||||
: undefined
|
||||
}
|
||||
data-testid="alert-postpone"
|
||||
>
|
||||
{t("executiveMeetings.alert.postpone")}
|
||||
@@ -1145,6 +1156,20 @@ function PostponeDialog({
|
||||
}
|
||||
};
|
||||
const handleErr = (err: unknown) => {
|
||||
// #635: map the external-meeting 409 to the localized reason so
|
||||
// schedule quick-action callers don't show the raw English error
|
||||
// from the server.
|
||||
const code =
|
||||
err && typeof err === "object" && "code" in err
|
||||
? (err as { code?: unknown }).code
|
||||
: undefined;
|
||||
if (code === "external_meeting_no_auto_postpone") {
|
||||
toast({
|
||||
title: t("executiveMeetings.alert.externalCannotPostpone"),
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: t("executiveMeetings.alert.saveFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Search, Send } from "lucide-react";
|
||||
import { useVisualViewport } from "@/hooks/use-visual-viewport";
|
||||
@@ -43,17 +43,102 @@ export function SendNoteDialog({
|
||||
const send = useSendNote();
|
||||
const [search, setSearch] = useState("");
|
||||
const [picked, setPicked] = useState<Set<number>>(new Set());
|
||||
// Make the recipients list shrink to fit when the iPad soft keyboard
|
||||
// takes over the bottom half of the screen. Without this the dialog
|
||||
// keeps its full height, gets centred under the keyboard, and the
|
||||
// people list ends up hidden — same root cause as #581 / the toolbar
|
||||
// fix in editable-cell.tsx (visualViewport excludes the keyboard but
|
||||
// not the QuickType strip, so we add a 48px buffer when the keyboard
|
||||
// is up).
|
||||
// #630: Keep the recipients list and the Send button visible above
|
||||
// the iOS soft keyboard.
|
||||
//
|
||||
// Background: the previous fix relied on `useVisualViewport` to
|
||||
// shrink `listMaxHeight` while the keyboard was up. That works on
|
||||
// desktop Safari and on iPad Safari (regular tab), but iPad PWAs
|
||||
// (Add to Home Screen) do NOT reliably resize visualViewport when
|
||||
// the soft keyboard opens — see the comment block in
|
||||
// `components/ui/dialog.tsx` and the reverted attempts #622–#624.
|
||||
// So the dialog stayed full height, kept its Radix center anchor
|
||||
// (top: 50%; translate(-50%, -50%)), and the keyboard covered the
|
||||
// entire bottom half — list and Send button included.
|
||||
//
|
||||
// The targeted, per-dialog fix here:
|
||||
// • Track focus on the search input directly. Focus is the most
|
||||
// reliable signal that the keyboard is (or is about to be) up,
|
||||
// and it doesn't depend on visualViewport reporting anything.
|
||||
// • While focused, anchor the dialog to the TOP of the viewport
|
||||
// instead of the center, and cap its height to ~50dvh. That
|
||||
// guarantees the entire dialog (title → input → list → buttons)
|
||||
// fits in the upper half of the screen, leaving the lower half
|
||||
// free for the keyboard regardless of what visualViewport says.
|
||||
// • When focus leaves, drop back to Radix's normal centered layout
|
||||
// so desktop and not-typing iPad keep the original look.
|
||||
//
|
||||
// `useVisualViewport` is still consulted as a refinement on devices
|
||||
// where it DOES update (desktop touch laptops, Android Chrome, iPad
|
||||
// Safari tab) so the list cap matches the actual usable height.
|
||||
const vv = useVisualViewport();
|
||||
const KEYBOARD_PREDICTION_INSET = vv.keyboardInset > 0 ? 48 : 0;
|
||||
const [searchFocused, setSearchFocused] = useState(false);
|
||||
// Device guard — only the focus-driven top-anchored override should
|
||||
// fire on touch devices where a soft keyboard actually appears.
|
||||
// Desktops with a hardware keyboard have no overlay to dodge, and
|
||||
// architect review flagged that applying the override on every
|
||||
// focus regressed their layout. `(pointer: coarse)` matches iPad,
|
||||
// iPhone, and Android touch primary inputs while excluding mouse-
|
||||
// driven desktop and touch laptops where the primary pointer is a
|
||||
// trackpad/mouse.
|
||||
const [isTouchPrimary, setIsTouchPrimary] = useState(false);
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return;
|
||||
const mq = window.matchMedia("(pointer: coarse)");
|
||||
setIsTouchPrimary(mq.matches);
|
||||
const handler = (e: MediaQueryListEvent) => setIsTouchPrimary(e.matches);
|
||||
mq.addEventListener?.("change", handler);
|
||||
return () => mq.removeEventListener?.("change", handler);
|
||||
}, []);
|
||||
const keyboardLikely = searchFocused && isTouchPrimary;
|
||||
// Slight delay on blur so the user can tap a list row without the
|
||||
// dialog jumping back to center mid-tap (a focus/blur race that
|
||||
// would shift the row out from under the finger).
|
||||
const blurTimeoutRef = useRef<number | null>(null);
|
||||
// Lifecycle cleanup — clear a pending blur timeout on unmount so we
|
||||
// don't schedule a setState on an unmounted component (and to satisfy
|
||||
// the architect's nitpick about the missing teardown).
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (blurTimeoutRef.current !== null) {
|
||||
window.clearTimeout(blurTimeoutRef.current);
|
||||
blurTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
const onSearchFocus = () => {
|
||||
if (blurTimeoutRef.current !== null) {
|
||||
window.clearTimeout(blurTimeoutRef.current);
|
||||
blurTimeoutRef.current = null;
|
||||
}
|
||||
setSearchFocused(true);
|
||||
};
|
||||
const onSearchBlur = () => {
|
||||
blurTimeoutRef.current = window.setTimeout(() => {
|
||||
setSearchFocused(false);
|
||||
blurTimeoutRef.current = null;
|
||||
}, 150);
|
||||
};
|
||||
|
||||
// Primary signal: visualViewport actually reports a keyboard inset.
|
||||
// Used on desktop touch laptops, iPad Safari (regular tab), and
|
||||
// Android Chrome where vv updates correctly.
|
||||
const keyboardActive = vv.keyboardInset > 0;
|
||||
// Fallback signal: iPad PWA (Add to Home Screen) doesn't reliably
|
||||
// update visualViewport when the keyboard opens (see comment in
|
||||
// components/ui/dialog.tsx). On touch-primary devices, treat focus
|
||||
// on the search input as evidence the keyboard is up so the dialog
|
||||
// still gets repositioned. This is a fallback only — when vv DOES
|
||||
// update, the primary branch above takes over.
|
||||
const keyboardFallback = !keyboardActive && searchFocused && isTouchPrimary;
|
||||
const KEYBOARD_PREDICTION_INSET =
|
||||
keyboardActive || keyboardFallback ? 48 : 0;
|
||||
const listMaxHeight = (() => {
|
||||
const usable = (vv.height || window.innerHeight) - KEYBOARD_PREDICTION_INSET;
|
||||
const baseHeight = vv.height || window.innerHeight;
|
||||
const assumedUsable = keyboardFallback
|
||||
? Math.min(baseHeight, window.innerHeight * 0.5)
|
||||
: baseHeight;
|
||||
const usable = assumedUsable - KEYBOARD_PREDICTION_INSET;
|
||||
// Reserve ~220px for the dialog chrome (title + search input +
|
||||
// footer + paddings) so the list never pushes the buttons off
|
||||
// screen. Clamp to a sensible min/max so desktop keeps the old
|
||||
@@ -62,6 +147,50 @@ export function SendNoteDialog({
|
||||
return forList;
|
||||
})();
|
||||
|
||||
// Dialog positioning override:
|
||||
// • Primary path (`keyboardActive`): compute the new vertical
|
||||
// centre from the *visual* viewport — anchored at
|
||||
// `offsetTop + height/2`, shifted up by the QuickType strip
|
||||
// allowance so the Send button isn't hidden under predictions.
|
||||
// Keep Radix's `translate(-50%, -50%)` so the dialog stays
|
||||
// centered around the new anchor.
|
||||
// • Fallback path (`keyboardFallback`): top-anchor at 6vh with
|
||||
// `translate(-50%, 0)` and cap `maxHeight: 50dvh` so the dialog
|
||||
// never crosses the screen midpoint where the keyboard sits.
|
||||
// • Otherwise: pass NO style so Radix's default centered layout
|
||||
// is fully restored (no leftover transition, no leftover top).
|
||||
const dialogStyle: React.CSSProperties | undefined = keyboardActive
|
||||
? {
|
||||
top: vv.offsetTop + vv.height / 2 - KEYBOARD_PREDICTION_INSET,
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
transition: "top 120ms ease",
|
||||
}
|
||||
: keyboardFallback
|
||||
? {
|
||||
// #634: bind `top` to `visualViewport.offsetTop` so when iPad
|
||||
// PWA Safari scrolls the layout viewport down behind the
|
||||
// soft keyboard, our top anchor follows the visible area
|
||||
// instead of landing above it (which previously hid the
|
||||
// dialog title + search input).
|
||||
top: `calc(${vv.offsetTop || 0}px + 6vh)`,
|
||||
// #634: pin `left: 50%` explicitly. Radix already sets it
|
||||
// via Tailwind, but the inline `transform` below replaces
|
||||
// Tailwind's `translate-x-[-50%] translate-y-[-50%]` so we
|
||||
// also restate the horizontal anchor to defend against any
|
||||
// animation/RTL interplay that was shifting the box left.
|
||||
left: "50%",
|
||||
transform: "translate(-50%, 0)",
|
||||
maxHeight: "50dvh",
|
||||
// #634: allow the dialog itself to scroll when its content
|
||||
// is taller than the cap — otherwise the header could be
|
||||
// clipped while only the bottom of the list/buttons stayed
|
||||
// visible.
|
||||
overflowY: "auto",
|
||||
transition: "top 120ms ease, transform 120ms ease",
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const candidates = useMemo(() => {
|
||||
const me = user?.id;
|
||||
return directory.filter((u) => u.id !== me);
|
||||
@@ -123,7 +252,11 @@ export function SendNoteDialog({
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-md" data-testid="send-note-dialog">
|
||||
<DialogContent
|
||||
className="max-w-md"
|
||||
{...(dialogStyle ? { style: dialogStyle } : {})}
|
||||
data-testid="send-note-dialog"
|
||||
>
|
||||
<DialogHeader className="pr-8">
|
||||
<DialogTitle>{t("notes.sendNote", "Send note")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -135,6 +268,8 @@ export function SendNoteDialog({
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onFocus={onSearchFocus}
|
||||
onBlur={onSearchBlur}
|
||||
placeholder={t("notes.recipientsPlaceholder", "Search people…")}
|
||||
className="ps-9"
|
||||
data-testid="send-note-search"
|
||||
|
||||
@@ -22,11 +22,18 @@ export function useAutoplayHint(): void {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent<AutoplayBlockedDetail>).detail;
|
||||
const isIos = !!detail?.isIos;
|
||||
// #628: the autoplay hint was previously an auto-dismissing
|
||||
// toast that the user could easily miss while looking at the
|
||||
// iPad from across the room. Make it sticky (no auto-dismiss)
|
||||
// so it stays on screen until acknowledged — otherwise the
|
||||
// first incoming order goes silently unnoticed and the user
|
||||
// never realises notifications need a tap to unlock.
|
||||
toast({
|
||||
title: t("notifSettings.autoplayHintTitle"),
|
||||
description: isIos
|
||||
? t("notifSettings.autoplayHintIos")
|
||||
: t("notifSettings.autoplayHint"),
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
};
|
||||
window.addEventListener(AUTOPLAY_BLOCKED_EVENT, handler);
|
||||
|
||||
@@ -26,7 +26,14 @@ import {
|
||||
|
||||
const BASE = import.meta.env.BASE_URL.replace(/\/$/, "");
|
||||
|
||||
const SOCKET_WARMUP_MS = 3000;
|
||||
// #628: warmup window after socket (re)connect during which chimes
|
||||
// are suppressed so a page-load flush of pending events doesn't sound
|
||||
// like an alarm. Lowered from 3000 → 1000 ms because on mobile the
|
||||
// socket disconnects/reconnects often (background tab, tunnel hop,
|
||||
// PWA wake-up); a 3-second window silently swallowed any order that
|
||||
// arrived in the first few seconds after returning to the app. 1 s
|
||||
// still covers the synchronous initial-burst at first connect.
|
||||
const SOCKET_WARMUP_MS = 1000;
|
||||
const nowMs = () =>
|
||||
typeof performance !== "undefined" ? performance.now() : Date.now();
|
||||
|
||||
@@ -93,10 +100,70 @@ export function useNotificationsSocket() {
|
||||
|
||||
const socket = io(window.location.origin, {
|
||||
path: `${BASE}/api/socket.io`,
|
||||
// #632: Behind Tailscale / proxies on iPad PWA the WebSocket can
|
||||
// be silently dropped by NAT/idle timeouts. Tighten the
|
||||
// reconnection budget so the client retries quickly instead of
|
||||
// sitting on a dead connection while orders pile up server-side.
|
||||
reconnection: true,
|
||||
reconnectionDelay: 500,
|
||||
reconnectionDelayMax: 3000,
|
||||
reconnectionAttempts: Infinity,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// #632: Track whether the socket has been disconnected at any
|
||||
// point so the next successful `connect` can refetch every
|
||||
// realtime-driven query — events that fire while the socket was
|
||||
// dead are lost forever otherwise, which is exactly the bug
|
||||
// (recipients seeing orders only after force-quitting the PWA).
|
||||
let wasDisconnected = false;
|
||||
// #632: Replay every event-driven query key on reconnect. Mirrors
|
||||
// the keys touched by individual socket.on handlers below — keep
|
||||
// this list in sync when adding new realtime channels. Role and
|
||||
// permission keys are included so a `role_permissions_changed`
|
||||
// event that fired while the socket was dead is recovered on
|
||||
// reconnect (per architect review).
|
||||
const refetchRealtimeState = () => {
|
||||
invalidate(getListNotificationsQueryKey());
|
||||
invalidate(getGetHomeStatsQueryKey());
|
||||
invalidate(getListIncomingServiceOrdersQueryKey());
|
||||
invalidate(getListMyServiceOrdersQueryKey());
|
||||
invalidate(["notes"]);
|
||||
invalidate(["note-folders"]);
|
||||
invalidate(["note-folders", "shared-with-me"]);
|
||||
invalidate(["/api/executive-meetings"]);
|
||||
invalidate(["/api/executive-meetings/alert-state"]);
|
||||
invalidate(["/api/executive-meetings/notifications"]);
|
||||
invalidate(getListAppsQueryKey());
|
||||
invalidate(getGetMeQueryKey());
|
||||
invalidate(getListRolesQueryKey());
|
||||
invalidate(getListPermissionsQueryKey());
|
||||
};
|
||||
|
||||
socket.on("connect", () => {
|
||||
// Reset the warmup window so a flush of pending notifications
|
||||
// delivered immediately after reconnect doesn't chime.
|
||||
connectedAtRef.current = nowMs();
|
||||
if (wasDisconnected) {
|
||||
wasDisconnected = false;
|
||||
refetchRealtimeState();
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("disconnect", (reason) => {
|
||||
wasDisconnected = true;
|
||||
// Surface unexpected disconnects in the console for field
|
||||
// debugging without breaking anything. `io client disconnect` is
|
||||
// the cleanup path on hook teardown — skip the noise for it.
|
||||
if (reason !== "io client disconnect") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("[socket] disconnected:", reason);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("connect_error", (err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("[socket] connect_error:", err.message);
|
||||
});
|
||||
|
||||
socket.on("notification_created", (payload?: { type?: string }) => {
|
||||
@@ -127,7 +194,18 @@ export function useNotificationsSocket() {
|
||||
const vibrate = isOrder
|
||||
? user.vibrationEnabledOrder
|
||||
: user.vibrationEnabledMeeting;
|
||||
notificationPlayer.play(soundId, { vibrate });
|
||||
// #628: order chimes use a tighter throttle window so a small
|
||||
// burst of incoming orders still rings for each one. Meeting
|
||||
// chimes keep the default 3s window — a single meeting event
|
||||
// can fan out to several listeners and we don't want a
|
||||
// buzzy double-chime. Buckets keep the two channels isolated
|
||||
// so an order burst can't starve a meeting alert (or vice
|
||||
// versa).
|
||||
notificationPlayer.play(soundId, {
|
||||
vibrate,
|
||||
throttleMs: isOrder ? 800 : 3000,
|
||||
throttleBucket: isOrder ? "order" : "meeting",
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("order_updated", () => {
|
||||
@@ -199,6 +277,7 @@ export function useNotificationsSocket() {
|
||||
playedNoteIdsRef.current.add(payload.noteId);
|
||||
notificationPlayer.play(user.notificationSoundNote, {
|
||||
vibrate: user.vibrationEnabledNote,
|
||||
throttleBucket: "note",
|
||||
});
|
||||
}
|
||||
// Only fall back to a toast when the floating popup did NOT
|
||||
@@ -284,6 +363,7 @@ export function useNotificationsSocket() {
|
||||
playedReplyIdsRef.current.add(replyId);
|
||||
notificationPlayer.play(user.notificationSoundNote, {
|
||||
vibrate: user.vibrationEnabledNote,
|
||||
throttleBucket: "note",
|
||||
});
|
||||
}
|
||||
// See `note_received` above: skip the redundant toast when the
|
||||
@@ -414,7 +494,46 @@ export function useNotificationsSocket() {
|
||||
},
|
||||
);
|
||||
|
||||
// #632: When the PWA / tab comes back to the foreground, iPad's
|
||||
// background suspension may have silently killed the WebSocket
|
||||
// even though `socket.connected` is still `true` (half-open
|
||||
// socket — the client hasn't sent a ping yet). Probing with a
|
||||
// timed roundtrip is the only reliable way to tell. We use the
|
||||
// socket.io v4 ack-timeout helper to round-trip a cheap event;
|
||||
// if the server doesn't ack within 3s, force-cycle the transport
|
||||
// so missed orders surface within ~3s of returning to the app
|
||||
// instead of waiting on the ping-timeout window (~15s).
|
||||
const onVisibility = () => {
|
||||
if (typeof document === "undefined") return;
|
||||
if (document.visibilityState !== "visible") return;
|
||||
if (!socket.connected) {
|
||||
wasDisconnected = true;
|
||||
socket.connect();
|
||||
return;
|
||||
}
|
||||
// Half-open probe: round-trip a cheap event through socket.io's
|
||||
// built-in ack channel. The server doesn't need to register a
|
||||
// handler — when no handler exists, the ack still fires with
|
||||
// no args, so a missing ack within 3s means the transport is
|
||||
// dead and we should force-cycle it.
|
||||
socket.timeout(3000).emit(
|
||||
"client_health_probe",
|
||||
(err: Error | null) => {
|
||||
if (err && socket.connected) {
|
||||
wasDisconnected = true;
|
||||
socket.disconnect().connect();
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (typeof document !== "undefined") {
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
}
|
||||
if (rafHandle !== null) {
|
||||
if (typeof window !== "undefined" && typeof window.cancelAnimationFrame === "function") {
|
||||
window.cancelAnimationFrame(rafHandle);
|
||||
|
||||
@@ -12,11 +12,14 @@ import { useEffect } from "react";
|
||||
* The field therefore stays wherever it sat in the dialog, often
|
||||
* behind the keyboard.
|
||||
*
|
||||
* Fix: when focus lands on an input/textarea/select/contenteditable
|
||||
* that lives inside a `[role="dialog"]`, scroll the target into the
|
||||
* centre of the dialog. Run twice (300ms / 600ms) because iPad's
|
||||
* keyboard animation finishes ~250-500ms after focus and the dialog's
|
||||
* own `max-height: 100dvh` recalc happens during that window.
|
||||
* Fix: when focus lands on an editable inside a `[role="dialog"]`,
|
||||
* walk up to the nearest scrollable ancestor (overflow-y auto/scroll)
|
||||
* inside the dialog and manually set its `scrollTop` so the target
|
||||
* sits in the middle of the visible band. We do NOT rely on
|
||||
* `scrollIntoView` because Safari iPadOS ignores it for descendants
|
||||
* of `position: fixed` containers — the scroll never happens. We run
|
||||
* the reveal twice (300ms / 600ms) to cover the iPad keyboard
|
||||
* animation window and the dialog's own `max-height: 100dvh` recalc.
|
||||
*
|
||||
* One global listener covers every dialog kind we use (Radix
|
||||
* DialogContent, the bespoke AdminFormDialog, ad-hoc role="dialog"
|
||||
@@ -24,14 +27,17 @@ import { useEffect } from "react";
|
||||
*/
|
||||
export function useScrollFocusedDialogField(): void {
|
||||
useEffect(() => {
|
||||
// Gate strictly to touch devices. Desktop browsers (mouse pointer)
|
||||
// never open a soft keyboard, so scrolling the focused field would
|
||||
// be unwanted movement. `pointer: coarse` matches touch-primary
|
||||
// devices (iPad, iPhone, Android) and is stable across orientation
|
||||
// / split-view changes, unlike visualViewport heuristics.
|
||||
// Gate to devices with any touch input. `pointer: coarse` alone
|
||||
// misses iPads connected to a Magic Keyboard / trackpad (their
|
||||
// *primary* pointer is reported as fine, so coarse never
|
||||
// matches), which is exactly the device class hitting this bug.
|
||||
// `any-pointer: coarse` catches "has touch at all", which is the
|
||||
// condition under which a soft keyboard can appear.
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
||||
const isTouch = window.matchMedia("(pointer: coarse)").matches;
|
||||
if (!isTouch) return;
|
||||
const hasTouch =
|
||||
window.matchMedia("(pointer: coarse)").matches ||
|
||||
window.matchMedia("(any-pointer: coarse)").matches;
|
||||
if (!hasTouch) return;
|
||||
|
||||
const isEditable = (el: Element | null): el is HTMLElement => {
|
||||
if (!el || !(el instanceof HTMLElement)) return false;
|
||||
@@ -40,6 +46,26 @@ export function useScrollFocusedDialogField(): void {
|
||||
return el.isContentEditable;
|
||||
};
|
||||
|
||||
// Walk up from `target` to find the nearest ancestor that actually
|
||||
// scrolls vertically. Stops at the dialog root (we never want to
|
||||
// scroll outside the dialog). Falls back to the dialog itself if
|
||||
// no scrollable inner container exists.
|
||||
const findScrollableAncestor = (
|
||||
target: HTMLElement,
|
||||
dialog: Element,
|
||||
): HTMLElement => {
|
||||
let node: HTMLElement | null = target.parentElement;
|
||||
while (node && node !== dialog) {
|
||||
const style = window.getComputedStyle(node);
|
||||
const oy = style.overflowY;
|
||||
if ((oy === "auto" || oy === "scroll") && node.scrollHeight > node.clientHeight) {
|
||||
return node;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return dialog as HTMLElement;
|
||||
};
|
||||
|
||||
const scrollTimers = new Set<number>();
|
||||
|
||||
const onFocusIn = (e: FocusEvent) => {
|
||||
@@ -47,21 +73,32 @@ export function useScrollFocusedDialogField(): void {
|
||||
if (!isEditable(target)) return;
|
||||
const dialog = target.closest('[role="dialog"]');
|
||||
if (!dialog) return;
|
||||
|
||||
const reveal = () => {
|
||||
try {
|
||||
target.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
} catch {
|
||||
try {
|
||||
target.scrollIntoView();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
const container = findScrollableAncestor(target, dialog);
|
||||
const cRect = container.getBoundingClientRect();
|
||||
const tRect = target.getBoundingClientRect();
|
||||
// Distance from the container's top to the target's top in
|
||||
// *content* coordinates: current scrollTop + (target top in
|
||||
// viewport - container top in viewport).
|
||||
const targetTopInContent = container.scrollTop + (tRect.top - cRect.top);
|
||||
// Centre the target within the visible band of the container.
|
||||
const desired = targetTopInContent - (container.clientHeight - tRect.height) / 2;
|
||||
const max = container.scrollHeight - container.clientHeight;
|
||||
container.scrollTop = Math.max(0, Math.min(desired, max));
|
||||
};
|
||||
const t1 = window.setTimeout(reveal, 300);
|
||||
const t2 = window.setTimeout(reveal, 600);
|
||||
scrollTimers.add(t1);
|
||||
scrollTimers.add(t2);
|
||||
|
||||
const schedule = (delay: number) => {
|
||||
const id = window.setTimeout(() => {
|
||||
scrollTimers.delete(id);
|
||||
reveal();
|
||||
}, delay);
|
||||
scrollTimers.add(id);
|
||||
return id;
|
||||
};
|
||||
const t1 = schedule(300);
|
||||
const t2 = schedule(600);
|
||||
|
||||
const onBlur = () => {
|
||||
window.clearTimeout(t1);
|
||||
window.clearTimeout(t2);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Publishes the live visual-viewport geometry as CSS custom properties
|
||||
* on `<html>` so stylesheets can size dialogs/sheets correctly above
|
||||
* the iOS soft keyboard.
|
||||
*
|
||||
* Why a JS hook (not CSS `dvh`):
|
||||
* - Safari iOS/iPadOS ignores the `interactive-widget=resizes-content`
|
||||
* viewport hint, so `100dvh` does NOT shrink when the soft keyboard
|
||||
* opens — it stays equal to the full screen. Any layout pinned with
|
||||
* `dvh` therefore sits behind the keyboard.
|
||||
* - `window.visualViewport` IS updated on iOS (including standalone
|
||||
* PWAs) whenever the keyboard, page-pinch zoom, or browser chrome
|
||||
* resizes the visible area. It's the only reliable signal.
|
||||
*
|
||||
* The hook writes two vars on `document.documentElement`:
|
||||
* --vv-height : current visualViewport.height in `px`
|
||||
* --vv-offset-top : current visualViewport.offsetTop in `px`
|
||||
* On browsers without `visualViewport` it falls back to `window.innerHeight`
|
||||
* and `0`, which matches the pre-existing `100vh` behaviour.
|
||||
*
|
||||
* Mount once at the app root (e.g. inside `NotificationsSocketBridge`).
|
||||
*/
|
||||
export function useVisualViewportVars(): void {
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
const vv = window.visualViewport ?? null;
|
||||
|
||||
const apply = () => {
|
||||
const h = vv ? vv.height : window.innerHeight;
|
||||
const top = vv ? vv.offsetTop : 0;
|
||||
root.style.setProperty("--vv-height", `${Math.round(h)}px`);
|
||||
root.style.setProperty("--vv-offset-top", `${Math.round(top)}px`);
|
||||
};
|
||||
|
||||
apply();
|
||||
|
||||
if (vv) {
|
||||
vv.addEventListener("resize", apply);
|
||||
vv.addEventListener("scroll", apply);
|
||||
}
|
||||
window.addEventListener("resize", apply);
|
||||
window.addEventListener("orientationchange", apply);
|
||||
|
||||
return () => {
|
||||
if (vv) {
|
||||
vv.removeEventListener("resize", apply);
|
||||
vv.removeEventListener("scroll", apply);
|
||||
}
|
||||
window.removeEventListener("resize", apply);
|
||||
window.removeEventListener("orientationchange", apply);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -470,36 +470,16 @@ html[dir="rtl"] .truncate {
|
||||
}
|
||||
|
||||
/*
|
||||
* Dialog keyboard-aware sheet override.
|
||||
*
|
||||
* iPad/iPhone PWAs don't reliably resize `window.visualViewport` when
|
||||
* the soft keyboard opens, so a JS-driven override based on it was
|
||||
* silently dead on Tahani's device — the dialog stayed centred (with
|
||||
* its consumer-provided `max-w-3xl translate(-50%, -50%)` classes) and
|
||||
* iOS scrolled the *layout* viewport upward to reveal the focused
|
||||
* input, leaving the dialog as a narrow strip pinned in the top-left
|
||||
* corner.
|
||||
*
|
||||
* Instead we drive everything from CSS. `:has(:focus)` matches whenever
|
||||
* any focusable descendant inside a `[role="dialog"]` (input, textarea,
|
||||
* select, contenteditable) holds focus — and on iOS that's exactly when
|
||||
* the soft keyboard is showing. We then pin the dialog as a
|
||||
* full-width sheet at the top of the dynamic viewport using `100dvh`,
|
||||
* which Safari shrinks automatically to exclude the keyboard. No JS
|
||||
* required, !important wins the cascade against any consumer Tailwind
|
||||
* classes, and the layout snaps back the moment focus leaves.
|
||||
* (Reverted) Earlier attempts (#622, #623, #624) added a global
|
||||
* `[role="dialog"]:has(:focus)` override + companion JS hooks to keep
|
||||
* the focused field above the iOS soft keyboard. Each iteration
|
||||
* regressed desktop browsers — on a touch-screen laptop the rule
|
||||
* fired on every input focus and broke Radix dialog layout (narrow
|
||||
* strip in the top-left). The rule, the helper class
|
||||
* (`.dialog-vv-wrapper`), and the global hooks are removed here.
|
||||
* Dialogs now render with their original Radix / consumer styles on
|
||||
* every device. The iOS keyboard issue will be revisited with a
|
||||
* targeted, per-dialog approach instead of a global override.
|
||||
*/
|
||||
[role="dialog"]:has(:is(input, textarea, select, [contenteditable="true"]):focus) {
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
bottom: auto !important;
|
||||
width: 100vw !important;
|
||||
max-width: none !important;
|
||||
max-height: 100dvh !important;
|
||||
transform: none !important;
|
||||
overflow-y: auto !important;
|
||||
-webkit-overflow-scrolling: touch !important;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -118,7 +118,13 @@ class NotificationPlayer {
|
||||
|
||||
// ---- Shared state ----
|
||||
private unlocked = false;
|
||||
private lastPlayAt = 0;
|
||||
// #628: per-bucket throttle timestamps. Was a single global value,
|
||||
// which coupled channels: lowering the order throttle to 800ms
|
||||
// caused rapid order chimes to keep refreshing the global timestamp
|
||||
// and starve meeting/note chimes (whose 3s window kept restarting).
|
||||
// Bucketing isolates each channel so order bursts can't mute
|
||||
// meeting/note alerts and vice versa.
|
||||
private lastPlayAtByBucket = new Map<string, number>();
|
||||
private hintDispatched = false;
|
||||
|
||||
private get useIos(): boolean {
|
||||
@@ -334,16 +340,34 @@ class NotificationPlayer {
|
||||
*/
|
||||
play(
|
||||
soundId: NotificationSoundId,
|
||||
opts: { vibrate?: boolean; bypassThrottle?: boolean } = {},
|
||||
opts: {
|
||||
vibrate?: boolean;
|
||||
bypassThrottle?: boolean;
|
||||
// #628: per-call throttle override. Order chimes drop to 800ms
|
||||
// so a small burst of incoming orders (rare but real during
|
||||
// service rushes) still rings for each one instead of swallowing
|
||||
// every order after the first for the next 3 seconds. Other
|
||||
// channels keep the original 3000ms coalescing window so a
|
||||
// single meeting fanout that touches several listeners doesn't
|
||||
// produce a buzzy double-chime.
|
||||
throttleMs?: number;
|
||||
// #628: throttle bucket key. Defaults to "default" so legacy
|
||||
// callers behave as before. Callers that want isolated coalescing
|
||||
// (e.g. "order" vs "meeting" vs "note") pass distinct buckets so
|
||||
// a burst on one channel can't extend the silence window of
|
||||
// another channel.
|
||||
throttleBucket?: string;
|
||||
} = {},
|
||||
): void {
|
||||
// Throttle: at most one sound every 3s to coalesce bursts of
|
||||
// notifications into a single chime. Settings "Test sound"
|
||||
// bypasses this so users can audition multiple sounds quickly.
|
||||
const now =
|
||||
typeof performance !== "undefined" ? performance.now() : Date.now();
|
||||
if (!opts.bypassThrottle) {
|
||||
if (now - this.lastPlayAt < 3000) return;
|
||||
this.lastPlayAt = now;
|
||||
const window =
|
||||
typeof opts.throttleMs === "number" ? opts.throttleMs : 3000;
|
||||
const bucket = opts.throttleBucket ?? "default";
|
||||
const last = this.lastPlayAtByBucket.get(bucket) ?? 0;
|
||||
if (now - last < window) return;
|
||||
this.lastPlayAtByBucket.set(bucket, now);
|
||||
}
|
||||
|
||||
const entry = SOUND_BY_ID.get(soundId) ?? SOUND_BY_ID.get("ding");
|
||||
|
||||
@@ -971,7 +971,9 @@
|
||||
"appName": "نظام Tx",
|
||||
"previous": "السابق",
|
||||
"next": "التالي",
|
||||
"empty": "(فارغ)"
|
||||
"empty": "(فارغ)",
|
||||
"back": "رجوع",
|
||||
"confirmDelete": "تأكيد الحذف"
|
||||
},
|
||||
"embeddedFrame": {
|
||||
"title": "تطبيق مدمج",
|
||||
@@ -1246,6 +1248,7 @@
|
||||
"atTime": "في {{time}}",
|
||||
"done": "تم",
|
||||
"postpone": "تأجيل",
|
||||
"externalCannotPostpone": "لا يمكن تأجيل اجتماع خارجي تلقائياً.",
|
||||
"dismiss": "تجاهل التنبيه",
|
||||
"dragHandle": "اسحب للتحريك",
|
||||
"dismissToast": "تم تجاهل التنبيه",
|
||||
@@ -1474,7 +1477,11 @@
|
||||
"platform": "المنصة",
|
||||
"status": "الحالة",
|
||||
"isHighlighted": "تمييز (لون أحمر)",
|
||||
"notes": "ملاحظات"
|
||||
"notes": "ملاحظات",
|
||||
"isExternal": "اجتماع خارجي",
|
||||
"isExternalHint": "يصبح صف الاجتماع باللون الأحمر تلقائياً، ولا يدخل في التأجيل التلقائي.",
|
||||
"isExternalOn": "نعم",
|
||||
"isExternalOff": "لا"
|
||||
},
|
||||
"attendees": {
|
||||
"label": "قائمة الحضور",
|
||||
@@ -1651,5 +1658,164 @@
|
||||
"saved": "تم الحفظ",
|
||||
"reset": "إعادة الافتراضي"
|
||||
}
|
||||
},
|
||||
"protocol": {
|
||||
"title": "العلاقات العامة والمراسم",
|
||||
"notes": "ملاحظات",
|
||||
"statusLabel": "الحالة",
|
||||
"confirmReject": "هل أنت متأكد من رفض هذا الطلب؟",
|
||||
"confirmDelete": "لا يمكن التراجع عن هذا الإجراء.",
|
||||
"tabs": {
|
||||
"dashboard": "لوحة المعلومات",
|
||||
"bookings": "حجز القاعات",
|
||||
"external": "اجتماعات خارجية",
|
||||
"giftsGroup": "الهدايا",
|
||||
"gifts": "المستودع",
|
||||
"issues": "الإهداءات",
|
||||
"photos": "الصور",
|
||||
"reports": "التقارير",
|
||||
"audit": "سجل النشاط",
|
||||
"rooms": "القاعات"
|
||||
},
|
||||
"status": {
|
||||
"pending": "قيد الانتظار",
|
||||
"approved": "معتمد",
|
||||
"rejected": "مرفوض",
|
||||
"cancelled": "ملغى",
|
||||
"issued": "تم الصرف",
|
||||
"scheduled": "مجدول",
|
||||
"completed": "منجز"
|
||||
},
|
||||
"giftKind": {
|
||||
"gift": "هدية",
|
||||
"shield": "درع"
|
||||
},
|
||||
"actions": {
|
||||
"approve": "اعتماد",
|
||||
"reject": "رفض",
|
||||
"cancel": "إلغاء",
|
||||
"approveIssue": "اعتماد وصرف"
|
||||
},
|
||||
"errors": {
|
||||
"generic": "تعذّر إتمام العملية."
|
||||
},
|
||||
"dashboard": {
|
||||
"activeRooms": "القاعات المتاحة",
|
||||
"todayBookings": "حجوزات اليوم",
|
||||
"pendingBookings": "حجوزات بانتظار الاعتماد",
|
||||
"upcomingExternal": "لقاءات خارجية قادمة",
|
||||
"giftStock": "إجمالي مخزون الهدايا",
|
||||
"pendingIssues": "إهداءات معلّقة",
|
||||
"roomsAvailableNow": "القاعات المتاحة الآن",
|
||||
"giftsIssuedThisMonth": "الدروع المصروفة هذا الشهر"
|
||||
},
|
||||
"bookings": {
|
||||
"new": "حجز جديد",
|
||||
"edit": "تعديل الحجز",
|
||||
"empty": "لا توجد حجوزات.",
|
||||
"requester": "مقدّم الطلب",
|
||||
"org": "الجهة",
|
||||
"phone": "الهاتف",
|
||||
"attendeeCount": "العدد المتوقع للحضور",
|
||||
"publicRequest": "طلب عام",
|
||||
"reference": "رقم الحجز",
|
||||
"department": "الإدارة",
|
||||
"entity": "اسم الجهة",
|
||||
"purpose": "الغرض من الاجتماع",
|
||||
"meetingType": "نوع الاجتماع",
|
||||
"meetingTypes": {
|
||||
"internal": "اجتماع داخلي",
|
||||
"external": "اجتماع مع جهة خارجية"
|
||||
},
|
||||
"keyAttendees": "أبرز الحضور",
|
||||
"attendeeSides": {
|
||||
"internal": "من الهيئة",
|
||||
"external": "خارج الهيئة"
|
||||
},
|
||||
"searchPlaceholder": "ابحث برقم الحجز أو الهاتف…",
|
||||
"searchEmpty": "لا توجد حجوزات مطابقة.",
|
||||
"roomLabel": "القاعة",
|
||||
"titleLabel": "عنوان الحجز",
|
||||
"startsAt": "من",
|
||||
"endsAt": "إلى",
|
||||
"viewList": "قائمة",
|
||||
"viewCalendar": "تقويم",
|
||||
"today": "اليوم",
|
||||
"book": "حجز",
|
||||
"emptyDay": "لا توجد حجوزات في هذا اليوم.",
|
||||
"shareLinkTitle": "رابط الحجز العام (للمشاركة مع الضيوف)",
|
||||
"copyLink": "نسخ الرابط",
|
||||
"linkCopied": "تم نسخ الرابط",
|
||||
"linkCopyFailed": "تعذّر نسخ الرابط"
|
||||
},
|
||||
"external": {
|
||||
"new": "لقاء جديد",
|
||||
"edit": "تعديل اللقاء",
|
||||
"empty": "لا توجد لقاءات خارجية.",
|
||||
"titleLabel": "عنوان اللقاء",
|
||||
"party": "الجهة",
|
||||
"location": "المكان",
|
||||
"startsAt": "التاريخ والوقت"
|
||||
},
|
||||
"gifts": {
|
||||
"new": "صنف جديد",
|
||||
"edit": "تعديل الصنف",
|
||||
"empty": "لا توجد أصناف.",
|
||||
"stock": "المخزون",
|
||||
"nameAr": "الاسم (عربي)",
|
||||
"nameEn": "الاسم (إنجليزي)",
|
||||
"kind": "النوع"
|
||||
},
|
||||
"rooms": {
|
||||
"new": "قاعة جديدة",
|
||||
"edit": "تعديل القاعة",
|
||||
"title": "القاعات",
|
||||
"inactive": "غير مفعّلة",
|
||||
"nameAr": "الاسم (عربي)",
|
||||
"nameEn": "الاسم (إنجليزي)",
|
||||
"capacity": "السعة",
|
||||
"location": "الموقع",
|
||||
"active": "مفعّلة",
|
||||
"isActive": "مفعّلة",
|
||||
"empty": "لا توجد قاعات."
|
||||
},
|
||||
"issues": {
|
||||
"new": "إهداء جديد",
|
||||
"empty": "لا توجد إهداءات.",
|
||||
"recipient": "المستفيد",
|
||||
"gift": "الصنف",
|
||||
"occasion": "المناسبة",
|
||||
"quantity": "الكمية"
|
||||
},
|
||||
"photos": {
|
||||
"newAlbum": "ألبوم جديد",
|
||||
"editAlbum": "تعديل الألبوم",
|
||||
"albumName": "اسم الألبوم",
|
||||
"linkMeeting": "ربط بلقاء خارجي",
|
||||
"noMeeting": "بدون ربط",
|
||||
"addPhotos": "إضافة صور",
|
||||
"download": "تنزيل",
|
||||
"downloadAll": "تنزيل الكل",
|
||||
"uploading": "جارٍ الرفع",
|
||||
"emptyAlbums": "لا توجد ألبومات.",
|
||||
"emptyPhotos": "لا توجد صور في هذا الألبوم.",
|
||||
"confirmDeleteAlbum": "سيتم حذف الألبوم وجميع صوره. لا يمكن التراجع.",
|
||||
"confirmDeletePhoto": "سيتم حذف هذه الصورة. لا يمكن التراجع."
|
||||
},
|
||||
"reports": {
|
||||
"byRoom": "الحجوزات حسب القاعة",
|
||||
"byGift": "الصرف حسب الصنف",
|
||||
"room": "القاعة",
|
||||
"gift": "الصنف",
|
||||
"total": "الإجمالي",
|
||||
"requests": "الطلبات",
|
||||
"issued": "المصروفة",
|
||||
"issuedQty": "الكمية المصروفة",
|
||||
"empty": "لا توجد بيانات.",
|
||||
"byExternal": "اللقاءات الخارجية حسب الحالة"
|
||||
},
|
||||
"audit": {
|
||||
"empty": "لا يوجد نشاط."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -883,7 +883,9 @@
|
||||
"appName": "Tx OS",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"empty": "(empty)"
|
||||
"empty": "(empty)",
|
||||
"back": "Back",
|
||||
"confirmDelete": "Confirm delete"
|
||||
},
|
||||
"embeddedFrame": {
|
||||
"title": "Embedded app",
|
||||
@@ -1174,6 +1176,7 @@
|
||||
"atTime": "at {{time}}",
|
||||
"done": "Done",
|
||||
"postpone": "Postpone",
|
||||
"externalCannotPostpone": "External meetings can't be auto-postponed.",
|
||||
"dismiss": "Dismiss alert",
|
||||
"dragHandle": "Drag to move",
|
||||
"dismissToast": "Alert dismissed",
|
||||
@@ -1347,7 +1350,11 @@
|
||||
"platform": "Platform",
|
||||
"status": "Status",
|
||||
"isHighlighted": "Highlight (red)",
|
||||
"notes": "Notes"
|
||||
"notes": "Notes",
|
||||
"isExternal": "External meeting",
|
||||
"isExternalHint": "Row turns red automatically and is excluded from auto-postpone.",
|
||||
"isExternalOn": "Yes",
|
||||
"isExternalOff": "No"
|
||||
},
|
||||
"attendees": {
|
||||
"label": "Attendees",
|
||||
@@ -1524,5 +1531,164 @@
|
||||
"saved": "Saved",
|
||||
"reset": "Reset to default"
|
||||
}
|
||||
},
|
||||
"protocol": {
|
||||
"title": "Public Relations & Protocol",
|
||||
"notes": "Notes",
|
||||
"statusLabel": "Status",
|
||||
"confirmReject": "Are you sure you want to reject this request?",
|
||||
"confirmDelete": "This action cannot be undone.",
|
||||
"tabs": {
|
||||
"dashboard": "Dashboard",
|
||||
"bookings": "Room Bookings",
|
||||
"external": "External Meetings",
|
||||
"giftsGroup": "Gifts",
|
||||
"gifts": "Warehouse",
|
||||
"issues": "Dedications",
|
||||
"photos": "Photos",
|
||||
"reports": "Reports",
|
||||
"audit": "Activity Log",
|
||||
"rooms": "Rooms"
|
||||
},
|
||||
"status": {
|
||||
"pending": "Pending",
|
||||
"approved": "Approved",
|
||||
"rejected": "Rejected",
|
||||
"cancelled": "Cancelled",
|
||||
"issued": "Issued",
|
||||
"scheduled": "Scheduled",
|
||||
"completed": "Completed"
|
||||
},
|
||||
"giftKind": {
|
||||
"gift": "Gift",
|
||||
"shield": "Shield"
|
||||
},
|
||||
"actions": {
|
||||
"approve": "Approve",
|
||||
"reject": "Reject",
|
||||
"cancel": "Cancel",
|
||||
"approveIssue": "Approve & issue"
|
||||
},
|
||||
"errors": {
|
||||
"generic": "The operation could not be completed."
|
||||
},
|
||||
"dashboard": {
|
||||
"activeRooms": "Active rooms",
|
||||
"todayBookings": "Today's bookings",
|
||||
"pendingBookings": "Bookings awaiting approval",
|
||||
"upcomingExternal": "Upcoming external meetings",
|
||||
"giftStock": "Total gift stock",
|
||||
"pendingIssues": "Pending dedications",
|
||||
"roomsAvailableNow": "Rooms available now",
|
||||
"giftsIssuedThisMonth": "Gifts issued this month"
|
||||
},
|
||||
"bookings": {
|
||||
"new": "New booking",
|
||||
"edit": "Edit booking",
|
||||
"empty": "No bookings yet.",
|
||||
"requester": "Requester",
|
||||
"org": "Organization",
|
||||
"phone": "Phone",
|
||||
"attendeeCount": "Expected attendees",
|
||||
"publicRequest": "Public request",
|
||||
"reference": "Booking ref",
|
||||
"department": "Department",
|
||||
"entity": "Entity",
|
||||
"purpose": "Meeting purpose",
|
||||
"meetingType": "Meeting type",
|
||||
"meetingTypes": {
|
||||
"internal": "Internal meeting",
|
||||
"external": "Meeting with external entity"
|
||||
},
|
||||
"keyAttendees": "Key attendees",
|
||||
"attendeeSides": {
|
||||
"internal": "From the authority",
|
||||
"external": "External"
|
||||
},
|
||||
"searchPlaceholder": "Search by booking ref or phone…",
|
||||
"searchEmpty": "No matching bookings.",
|
||||
"roomLabel": "Room",
|
||||
"titleLabel": "Booking title",
|
||||
"startsAt": "From",
|
||||
"endsAt": "To",
|
||||
"viewList": "List",
|
||||
"viewCalendar": "Calendar",
|
||||
"today": "Today",
|
||||
"book": "Book",
|
||||
"emptyDay": "No bookings on this day.",
|
||||
"shareLinkTitle": "Public booking link (share with guests)",
|
||||
"copyLink": "Copy link",
|
||||
"linkCopied": "Link copied",
|
||||
"linkCopyFailed": "Could not copy the link"
|
||||
},
|
||||
"external": {
|
||||
"new": "New meeting",
|
||||
"edit": "Edit meeting",
|
||||
"empty": "No external meetings yet.",
|
||||
"titleLabel": "Meeting title",
|
||||
"party": "Party",
|
||||
"location": "Location",
|
||||
"startsAt": "Date & time"
|
||||
},
|
||||
"gifts": {
|
||||
"new": "New item",
|
||||
"edit": "Edit item",
|
||||
"empty": "No items yet.",
|
||||
"stock": "Stock",
|
||||
"nameAr": "Name (Arabic)",
|
||||
"nameEn": "Name (English)",
|
||||
"kind": "Kind"
|
||||
},
|
||||
"rooms": {
|
||||
"new": "New room",
|
||||
"edit": "Edit room",
|
||||
"title": "Rooms",
|
||||
"inactive": "inactive",
|
||||
"nameAr": "Name (Arabic)",
|
||||
"nameEn": "Name (English)",
|
||||
"capacity": "Capacity",
|
||||
"location": "Location",
|
||||
"active": "Active",
|
||||
"isActive": "Active",
|
||||
"empty": "No rooms."
|
||||
},
|
||||
"issues": {
|
||||
"new": "New dedication",
|
||||
"empty": "No dedications yet.",
|
||||
"recipient": "Recipient",
|
||||
"gift": "Item",
|
||||
"occasion": "Occasion",
|
||||
"quantity": "Quantity"
|
||||
},
|
||||
"photos": {
|
||||
"newAlbum": "New album",
|
||||
"editAlbum": "Edit album",
|
||||
"albumName": "Album name",
|
||||
"linkMeeting": "Link to external meeting",
|
||||
"noMeeting": "No link",
|
||||
"addPhotos": "Add photos",
|
||||
"download": "Download",
|
||||
"downloadAll": "Download all",
|
||||
"uploading": "Uploading",
|
||||
"emptyAlbums": "No albums yet.",
|
||||
"emptyPhotos": "No photos in this album.",
|
||||
"confirmDeleteAlbum": "This deletes the album and all its photos. This cannot be undone.",
|
||||
"confirmDeletePhoto": "This photo will be deleted. This cannot be undone."
|
||||
},
|
||||
"reports": {
|
||||
"byRoom": "Bookings by room",
|
||||
"byGift": "Issuance by item",
|
||||
"room": "Room",
|
||||
"gift": "Item",
|
||||
"total": "Total",
|
||||
"requests": "Requests",
|
||||
"issued": "Issued",
|
||||
"issuedQty": "Issued qty",
|
||||
"empty": "No data.",
|
||||
"byExternal": "External meetings by status"
|
||||
},
|
||||
"audit": {
|
||||
"empty": "No activity yet."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "wouter";
|
||||
import { useLocation, useSearch } from "wouter";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -194,6 +194,10 @@ type Meeting = {
|
||||
// is the same for every viewer. NULL/missing = "default" (no tint).
|
||||
// Allowed values come from ROW_COLOR_OPTIONS below.
|
||||
rowColor?: string | null;
|
||||
// #635: External meeting flag. When true, the row is force-tinted red
|
||||
// and the alert-postpone button is disabled (the server also rejects
|
||||
// /postpone-minutes with `external_meeting_no_auto_postpone`).
|
||||
isExternal?: boolean;
|
||||
// #486: optimistic-lock token used by /executive-meetings/swap-times
|
||||
// (the schedule row quick-actions Move up / Move down popover) and
|
||||
// by the existing PostponeDialog when reused from the schedule.
|
||||
@@ -1361,7 +1365,12 @@ function ScheduleSection({
|
||||
const rowColors = useMemo<Record<number, string>>(() => {
|
||||
const out: Record<number, string> = {};
|
||||
for (const m of meetings) {
|
||||
if (m.rowColor) out[m.id] = m.rowColor;
|
||||
// #635: external meetings are always tinted red regardless of
|
||||
// (and overriding) any manually-set rowColor, so the visual cue
|
||||
// matches the flag even on legacy rows whose rowColor was set
|
||||
// before isExternal existed.
|
||||
if (m.isExternal) out[m.id] = "red";
|
||||
else if (m.rowColor) out[m.id] = m.rowColor;
|
||||
}
|
||||
return out;
|
||||
}, [meetings]);
|
||||
@@ -2441,6 +2450,84 @@ function ScheduleSection({
|
||||
};
|
||||
}, [highlightEditRowId, meetings]);
|
||||
|
||||
// --- Deep-link: /meetings?meeting=<id> (optionally &date=YYYY-MM-DD) ---
|
||||
// Opened from a notification (in-app list or system Web Push). We switch
|
||||
// the schedule to the meeting's day, then scroll + flash its row reusing
|
||||
// the same accent-ring as the row "Edit" affordance. When no date is
|
||||
// supplied we resolve it from the meeting id via the API so the same link
|
||||
// works regardless of which day is currently shown.
|
||||
const deepLinkSearch = useSearch();
|
||||
const deepLinkHandledRef = useRef<string | null>(null);
|
||||
const [pendingDeepLinkMeetingId, setPendingDeepLinkMeetingId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(deepLinkSearch);
|
||||
const meetingParam = params.get("meeting");
|
||||
if (!meetingParam) return;
|
||||
if (deepLinkHandledRef.current === meetingParam) return;
|
||||
const id = Number(meetingParam);
|
||||
if (!Number.isInteger(id) || id <= 0) return;
|
||||
deepLinkHandledRef.current = meetingParam;
|
||||
setPendingDeepLinkMeetingId(id);
|
||||
const dateParam = params.get("date");
|
||||
if (dateParam && /^\d{4}-\d{2}-\d{2}$/.test(dateParam)) {
|
||||
if (dateParam !== date) onDateChange(dateParam);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void apiJson<{ meetingDate?: string }>(`/api/executive-meetings/${id}`)
|
||||
.then((m) => {
|
||||
if (cancelled) return;
|
||||
if (m?.meetingDate && m.meetingDate !== date) onDateChange(m.meetingDate);
|
||||
})
|
||||
.catch(() => {
|
||||
// Meeting may have been deleted/moved — leave the user on the
|
||||
// current day rather than surfacing an error for a stale link.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [deepLinkSearch, date, onDateChange]);
|
||||
useEffect(() => {
|
||||
if (pendingDeepLinkMeetingId == null) return;
|
||||
if (!meetings.some((m) => m.id === pendingDeepLinkMeetingId)) return;
|
||||
setHighlightEditRowId(pendingDeepLinkMeetingId);
|
||||
setPendingDeepLinkMeetingId(null);
|
||||
deepLinkHandledRef.current = null;
|
||||
if (typeof window !== "undefined") {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete("meeting");
|
||||
params.delete("date");
|
||||
const qs = params.toString();
|
||||
const next = `${window.location.pathname}${qs ? `?${qs}` : ""}`;
|
||||
window.history.replaceState(null, "", next);
|
||||
}
|
||||
}, [pendingDeepLinkMeetingId, meetings]);
|
||||
useEffect(() => {
|
||||
if (pendingDeepLinkMeetingId == null) return;
|
||||
// Safety net for stale links: if the target meeting never materializes
|
||||
// (deleted, moved to another day we can't resolve, or simply not in the
|
||||
// loaded set), clear the pending state and strip the param after a grace
|
||||
// period so the URL doesn't linger and a later valid link still works.
|
||||
const stale = window.setTimeout(() => {
|
||||
setPendingDeepLinkMeetingId(null);
|
||||
deepLinkHandledRef.current = null;
|
||||
if (typeof window !== "undefined") {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete("meeting");
|
||||
params.delete("date");
|
||||
const qs = params.toString();
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
}
|
||||
}, 8000);
|
||||
return () => window.clearTimeout(stale);
|
||||
}, [pendingDeepLinkMeetingId]);
|
||||
|
||||
// #489: Drag-from-anywhere on the row rotates meeting CONTENT through
|
||||
// the day's fixed time slots. The (startTime, endTime, dailyNumber)
|
||||
// tuple of each chronological slot stays anchored to its physical
|
||||
@@ -3403,10 +3490,11 @@ function ScheduleSection({
|
||||
sortOrder: idx,
|
||||
kind: a.kind ?? "person",
|
||||
})),
|
||||
// #635/#639: external meeting flag — coerce to real
|
||||
// boolean so an undefined state never silently drops
|
||||
// the field from the PATCH body.
|
||||
isExternal: Boolean(editingMeeting.isExternal),
|
||||
};
|
||||
if (editingMeeting.dailyNumber.trim()) {
|
||||
body.dailyNumber = Number(editingMeeting.dailyNumber);
|
||||
}
|
||||
try {
|
||||
await apiJson(
|
||||
`/api/executive-meetings/${editingMeeting.id}`,
|
||||
@@ -4549,7 +4637,7 @@ function MeetingRow({
|
||||
return (
|
||||
<td
|
||||
key="meeting"
|
||||
className="border border-gray-300 px-3 py-3 align-middle text-[#0B1E3F] relative group"
|
||||
className={`border border-gray-300 px-3 py-3 align-middle text-center ${meeting.isExternal ? "text-[#dc2626]" : "text-[#0B1E3F]"} relative group`}
|
||||
style={tintedCellStyle(col, true)}
|
||||
>
|
||||
<EditableCell
|
||||
@@ -4557,14 +4645,20 @@ function MeetingRow({
|
||||
onSave={onSaveTitle}
|
||||
ariaLabel={t("executiveMeetings.editMeetingTitle")}
|
||||
dir={isRtl ? "rtl" : "ltr"}
|
||||
className="font-medium leading-snug break-words"
|
||||
// Center the title horizontally for the view-mode wrapper AND
|
||||
// any inner `<p>` Tiptap saved — `safeHtml` keeps `style`
|
||||
// attributes (so users can colour / size text), which means a
|
||||
// stray `text-align` saved by the editor would otherwise leak
|
||||
// into the cell. The `!` modifier outranks the inline style so
|
||||
// the title always stays centered regardless of what's stored.
|
||||
className="font-medium leading-snug break-words text-center [&_p]:!text-center"
|
||||
disabled={!canMutate}
|
||||
testId={`em-edit-title-${meeting.id}`}
|
||||
startInEditMode={autoEditTitle}
|
||||
onStartedEditing={onAutoEditTitleConsumed}
|
||||
/>
|
||||
{meeting.location && (
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5 break-words">
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5 break-words text-center">
|
||||
{meeting.location}
|
||||
</div>
|
||||
)}
|
||||
@@ -4576,7 +4670,7 @@ function MeetingRow({
|
||||
return (
|
||||
<td
|
||||
key="attendees"
|
||||
className="border border-gray-300 px-3 py-3 align-middle relative group"
|
||||
className="border border-gray-300 px-3 py-3 align-middle text-center relative group"
|
||||
style={tintedCellStyle(col, false)}
|
||||
>
|
||||
<AttendeesCell
|
||||
@@ -4661,7 +4755,7 @@ function MeetingRow({
|
||||
}}
|
||||
ariaLabel={t("executiveMeetings.merge.editLabel")}
|
||||
dir={isRtl ? "rtl" : "ltr"}
|
||||
className="font-medium leading-snug break-words"
|
||||
className="font-medium leading-snug break-words text-center [&_p]:!text-center"
|
||||
disabled={!canMutate}
|
||||
placeholder={t("executiveMeetings.merge.placeholder")}
|
||||
testId={`em-merge-edit-${meeting.id}`}
|
||||
@@ -4984,6 +5078,12 @@ function MeetingRow({
|
||||
setQuickOpen(false);
|
||||
onQuickPostpone?.();
|
||||
}}
|
||||
disabled={meeting.isExternal}
|
||||
title={
|
||||
meeting.isExternal
|
||||
? t("executiveMeetings.alert.externalCannotPostpone")
|
||||
: undefined
|
||||
}
|
||||
className={`flex-1 min-w-0 gap-2 px-3 py-2 h-10 text-sm font-medium shadow-sm ${isRtl ? "flex-row-reverse" : ""}`}
|
||||
data-testid={`em-row-quick-postpone-${meeting.id}`}
|
||||
>
|
||||
@@ -5862,7 +5962,7 @@ function AttendeeFlow({
|
||||
}
|
||||
|
||||
if (items.length === 0 && !showAdd && !isPending) {
|
||||
return <span className="text-xs text-gray-500">—</span>;
|
||||
return <span className="block text-center text-xs text-gray-500">—</span>;
|
||||
}
|
||||
|
||||
// Locate the listIdx where the inline pending ghost should appear, when
|
||||
@@ -6022,7 +6122,7 @@ function AttendeeFlow({
|
||||
};
|
||||
|
||||
return (
|
||||
<ul className="flex flex-wrap justify-center items-baseline gap-x-4 gap-y-0.5 text-[#0B1E3F] leading-snug">
|
||||
<ul className={`flex flex-wrap justify-center items-baseline gap-x-4 gap-y-0.5 text-[#0B1E3F] leading-snug`}>
|
||||
{items.flatMap(({ a, i }, listIdx) => {
|
||||
const isSub = (a.kind ?? "person") === "subheading";
|
||||
const nodes: ReactNode[] = [];
|
||||
@@ -6266,7 +6366,6 @@ type MeetingFormState = {
|
||||
titleAr: string;
|
||||
titleEn: string;
|
||||
meetingDate: string;
|
||||
dailyNumber: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
location: string;
|
||||
@@ -6276,6 +6375,9 @@ type MeetingFormState = {
|
||||
isHighlighted: boolean;
|
||||
notes: string;
|
||||
attendees: Attendee[];
|
||||
// #635: external meeting flag — when true the row is force-tinted red
|
||||
// and the alert-postpone button is disabled.
|
||||
isExternal: boolean;
|
||||
};
|
||||
|
||||
// Generate a stable client-only attendee id for the manage-dialog DnD.
|
||||
@@ -6294,7 +6396,6 @@ function emptyMeetingForm(date: string): MeetingFormState {
|
||||
titleAr: "",
|
||||
titleEn: "",
|
||||
meetingDate: date,
|
||||
dailyNumber: "",
|
||||
startTime: "",
|
||||
endTime: "",
|
||||
location: "",
|
||||
@@ -6304,6 +6405,7 @@ function emptyMeetingForm(date: string): MeetingFormState {
|
||||
isHighlighted: false,
|
||||
notes: "",
|
||||
attendees: [],
|
||||
isExternal: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6602,7 +6704,6 @@ function ManageSection({
|
||||
titleAr: htmlToPlainText(m.titleAr),
|
||||
titleEn: htmlToPlainText(m.titleEn ?? ""),
|
||||
meetingDate: m.meetingDate,
|
||||
dailyNumber: String(m.dailyNumber),
|
||||
startTime: m.startTime ? m.startTime.slice(0, 5) : "",
|
||||
endTime: m.endTime ? m.endTime.slice(0, 5) : "",
|
||||
location: m.location ?? "",
|
||||
@@ -6622,6 +6723,7 @@ function ManageSection({
|
||||
name: htmlToPlainText(a.name),
|
||||
_sid: genAttendeeSid(),
|
||||
})),
|
||||
isExternal: m.isExternal ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6675,8 +6777,13 @@ function ManageSection({
|
||||
// schedule survive a save through the Manage dialog.
|
||||
kind: a.kind ?? "person",
|
||||
})),
|
||||
// #635/#639: send the external flag as a real boolean so the
|
||||
// server can force rowColor='red' and the cascade/postpone rules
|
||||
// know to skip it. Coerce defensively — a stale render where
|
||||
// state.isExternal is undefined would otherwise silently send
|
||||
// `undefined` and the server PATCH would skip the column.
|
||||
isExternal: Boolean(editing.isExternal),
|
||||
};
|
||||
if (editing.dailyNumber.trim()) body.dailyNumber = Number(editing.dailyNumber);
|
||||
try {
|
||||
if (editing.id == null) {
|
||||
await apiJson(`/api/executive-meetings`, { method: "POST", body });
|
||||
@@ -7553,16 +7660,31 @@ function MeetingFormDialog({
|
||||
onChange={(e) => update("meetingDate", e.target.value)}
|
||||
/>
|
||||
</FormRow>
|
||||
{/* #635/#639: external-meeting toggle replaces the
|
||||
daily-number input (numbering is now fully auto-assigned
|
||||
by the server). When ON the row is force-tinted red and
|
||||
the alert-postpone button is disabled.
|
||||
`full` so it spans both grid columns on its own row —
|
||||
prevents the toggle from sharing a row with the date
|
||||
input and colliding with the time pickers below on
|
||||
narrow screens. */}
|
||||
<FormRow
|
||||
label={t("executiveMeetings.manage.field.dailyNumber")}
|
||||
hint={t("executiveMeetings.manage.field.dailyNumberHint")}
|
||||
label={t("executiveMeetings.manage.field.isExternal")}
|
||||
hint={t("executiveMeetings.manage.field.isExternalHint")}
|
||||
full
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={state.dailyNumber}
|
||||
onChange={(e) => update("dailyNumber", e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center gap-3 py-1">
|
||||
<Switch
|
||||
checked={state.isExternal}
|
||||
onCheckedChange={(v) => update("isExternal", Boolean(v))}
|
||||
data-testid="em-form-isExternal"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
{state.isExternal
|
||||
? t("executiveMeetings.manage.field.isExternalOn")
|
||||
: t("executiveMeetings.manage.field.isExternalOff")}
|
||||
</span>
|
||||
</div>
|
||||
</FormRow>
|
||||
<FormRow
|
||||
label={t("executiveMeetings.manage.field.startTime")}
|
||||
@@ -7843,6 +7965,7 @@ function AuditDiffSummary({
|
||||
"platform",
|
||||
"status",
|
||||
"isHighlighted",
|
||||
"isExternal",
|
||||
"notes",
|
||||
"assignedTo",
|
||||
"taskType",
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "@workspace/api-client-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { usePushSubscription } from "@/hooks/use-push-subscription";
|
||||
import { useReceivedNotes } from "@/lib/notes-api";
|
||||
import { useIncomingNotePopup } from "@/contexts/IncomingNotePopupContext";
|
||||
import {
|
||||
@@ -491,6 +492,7 @@ export default function HomePage() {
|
||||
const notesPulse = notePopupQueueLength > 0;
|
||||
|
||||
const logout = useLogout();
|
||||
const pushSub = usePushSubscription();
|
||||
|
||||
const openApp = (app: App) => {
|
||||
// Fire-and-forget: keepalive ensures the request survives any
|
||||
@@ -521,7 +523,31 @@ export default function HomePage() {
|
||||
? (user?.displayNameAr ?? user?.username ?? "")
|
||||
: (user?.displayNameEn ?? user?.username ?? "");
|
||||
|
||||
const handleLogout = () => {
|
||||
const handleLogout = async () => {
|
||||
// #626: tear down this browser's Web Push subscription BEFORE
|
||||
// hitting /auth/logout, so the local pushManager endpoint and the
|
||||
// server row both go away. The server-side logout also deletes
|
||||
// every push_subscriptions row for this user as a safety net, but
|
||||
// doing the browser unsubscribe first means the OS-level
|
||||
// subscription is gone too — otherwise the SW would keep an
|
||||
// orphan endpoint that the next signed-in user could inherit on
|
||||
// resubscribe.
|
||||
// Race the browser unsubscribe against a 1.5s timeout. `disable()`
|
||||
// awaits `navigator.serviceWorker.ready`, which can hang forever
|
||||
// if the SW never activates (rare but observed on first iOS PWA
|
||||
// launch). A stalled unsubscribe must not block the user from
|
||||
// signing out — the server-side delete in /auth/logout is the
|
||||
// authoritative cleanup, so a missed browser unsubscribe just
|
||||
// leaves an orphan pushManager endpoint that the OS will GC on
|
||||
// its own schedule.
|
||||
try {
|
||||
await Promise.race([
|
||||
pushSub.disable(),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 1500)),
|
||||
]);
|
||||
} catch {
|
||||
// Best-effort: a failed unsubscribe must not block sign-out.
|
||||
}
|
||||
logout.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
queryClient.clear();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "wouter";
|
||||
import { useLocation, useSearch } from "wouter";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
useListMyServiceOrders,
|
||||
@@ -159,10 +159,12 @@ function OrderCard({
|
||||
order,
|
||||
onDelete,
|
||||
onReorder,
|
||||
highlighted = false,
|
||||
}: {
|
||||
order: ServiceOrder;
|
||||
onDelete: (id: number) => void;
|
||||
onReorder: (order: ServiceOrder) => void;
|
||||
highlighted?: boolean;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -236,7 +238,13 @@ function OrderCard({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass-panel rounded-xl p-4 flex flex-col gap-3">
|
||||
<div
|
||||
data-order-id={order.id}
|
||||
className={cn(
|
||||
"glass-panel rounded-xl p-4 flex flex-col gap-3 transition-shadow",
|
||||
highlighted && "ring-2 ring-primary ring-offset-2",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<OrderImage src={img} alt={name} />
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -433,6 +441,53 @@ export default function MyOrdersPage() {
|
||||
)
|
||||
: [];
|
||||
|
||||
// Deep-link from a notification: /my-orders?order=<id> scrolls to and
|
||||
// flashes the matching order card, then strips the param so re-renders
|
||||
// don't re-trigger the jump.
|
||||
const orderSearch = useSearch();
|
||||
const [highlightOrderId, setHighlightOrderId] = useState<number | null>(null);
|
||||
const highlightHandledRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(orderSearch);
|
||||
const orderParam = params.get("order");
|
||||
if (!orderParam) return;
|
||||
if (highlightHandledRef.current === orderParam) return;
|
||||
const id = Number(orderParam);
|
||||
if (!Number.isInteger(id) || id <= 0) return;
|
||||
highlightHandledRef.current = orderParam;
|
||||
setHighlightOrderId(id);
|
||||
if (typeof window !== "undefined") {
|
||||
params.delete("order");
|
||||
const qs = params.toString();
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
}
|
||||
}, [orderSearch]);
|
||||
useEffect(() => {
|
||||
if (highlightOrderId == null) return;
|
||||
if (!sortedOrders.some((o) => o.id === highlightOrderId)) return;
|
||||
const rafId = window.requestAnimationFrame(() => {
|
||||
const el = document.querySelector(`[data-order-id="${highlightOrderId}"]`);
|
||||
if (el && "scrollIntoView" in el) {
|
||||
(el as HTMLElement).scrollIntoView({
|
||||
block: "center",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
});
|
||||
const clear = window.setTimeout(() => {
|
||||
setHighlightOrderId(null);
|
||||
highlightHandledRef.current = null;
|
||||
}, 2000);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
window.clearTimeout(clear);
|
||||
};
|
||||
}, [highlightOrderId, sortedOrders]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen os-bg flex flex-col">
|
||||
<div className="glass-panel border-b border-slate-200/70 px-4 py-4 flex items-center gap-3 sticky top-0 z-10">
|
||||
@@ -488,6 +543,7 @@ export default function MyOrdersPage() {
|
||||
<OrderCard
|
||||
key={o.id}
|
||||
order={o}
|
||||
highlighted={o.id === highlightOrderId}
|
||||
onDelete={(id) => scheduleDelete([id])}
|
||||
onReorder={(order) =>
|
||||
setReorderTarget({
|
||||
|
||||
@@ -14,6 +14,34 @@ import { AppDock } from "@/components/app-dock";
|
||||
import { formatDateTime } from "@/lib/i18n-format";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
// Map a notification to the in-app deep-link it should open when tapped.
|
||||
// Mirrors the Web Push `url` built on the server so the in-app list and a
|
||||
// system push notification land on the exact same item: a note opens its
|
||||
// thread, a meeting opens that meeting on its day, and an order opens the
|
||||
// right order surface ("Incoming orders" for a fresh order to claim,
|
||||
// otherwise the recipient's "My orders"). Returns null when there is no
|
||||
// specific destination (the tap then only marks the item read).
|
||||
function notificationDestination(n: {
|
||||
type: string;
|
||||
relatedType?: string | null;
|
||||
relatedId?: number | null;
|
||||
}): string | null {
|
||||
const id = n.relatedId;
|
||||
switch (n.type) {
|
||||
case "note":
|
||||
return id ? `/notes?thread=${id}` : "/notes";
|
||||
case "executive_meeting":
|
||||
return id ? `/meetings?meeting=${id}` : "/meetings";
|
||||
case "order": {
|
||||
const base =
|
||||
n.relatedType === "incoming_order" ? "/orders/incoming" : "/my-orders";
|
||||
return id ? `${base}?order=${id}` : base;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [, setLocation] = useLocation();
|
||||
@@ -53,10 +81,15 @@ export default function NotificationsPage() {
|
||||
const handleNotificationClick = (n: {
|
||||
id: number;
|
||||
isRead: boolean;
|
||||
type: string;
|
||||
relatedType?: string | null;
|
||||
relatedId?: number | null;
|
||||
}) => {
|
||||
if (!n.isRead) {
|
||||
handleMarkOne(n.id);
|
||||
}
|
||||
const dest = notificationDestination(n);
|
||||
if (dest) setLocation(dest);
|
||||
};
|
||||
|
||||
const unreadCount = notifications?.filter((n) => !n.isRead).length ?? 0;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "wouter";
|
||||
import { useLocation, useSearch } from "wouter";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
useListIncomingServiceOrders,
|
||||
@@ -105,6 +105,7 @@ function IncomingOrderCard({
|
||||
selected,
|
||||
onToggleSelected,
|
||||
onRequestDelete,
|
||||
highlighted = false,
|
||||
}: {
|
||||
order: ServiceOrder;
|
||||
meId: number;
|
||||
@@ -112,6 +113,7 @@ function IncomingOrderCard({
|
||||
selected: boolean;
|
||||
onToggleSelected: (id: number) => void;
|
||||
onRequestDelete: (id: number) => void;
|
||||
highlighted?: boolean;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
@@ -279,6 +281,7 @@ function IncomingOrderCard({
|
||||
className={cn(
|
||||
"glass-panel rounded-xl p-3 flex flex-col gap-3 transition-colors",
|
||||
selected && "ring-2 ring-amber-400 bg-amber-50/40",
|
||||
!selected && highlighted && "ring-2 ring-primary ring-offset-2",
|
||||
)}
|
||||
data-testid={`incoming-order-card-${order.id}`}
|
||||
>
|
||||
@@ -406,6 +409,55 @@ export default function OrdersIncomingPage() {
|
||||
[mineIds, unclaimedIds],
|
||||
);
|
||||
|
||||
// Deep-link from a notification: /orders/incoming?order=<id> scrolls to
|
||||
// and flashes the matching order card, then strips the param so
|
||||
// re-renders don't re-trigger the jump.
|
||||
const orderSearch = useSearch();
|
||||
const [highlightOrderId, setHighlightOrderId] = useState<number | null>(null);
|
||||
const highlightHandledRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(orderSearch);
|
||||
const orderParam = params.get("order");
|
||||
if (!orderParam) return;
|
||||
if (highlightHandledRef.current === orderParam) return;
|
||||
const id = Number(orderParam);
|
||||
if (!Number.isInteger(id) || id <= 0) return;
|
||||
highlightHandledRef.current = orderParam;
|
||||
setHighlightOrderId(id);
|
||||
if (typeof window !== "undefined") {
|
||||
params.delete("order");
|
||||
const qs = params.toString();
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
}
|
||||
}, [orderSearch]);
|
||||
useEffect(() => {
|
||||
if (highlightOrderId == null) return;
|
||||
if (![...mine, ...unclaimed].some((o) => o.id === highlightOrderId)) return;
|
||||
const rafId = window.requestAnimationFrame(() => {
|
||||
const el = document.querySelector(
|
||||
`[data-testid="incoming-order-card-${highlightOrderId}"]`,
|
||||
);
|
||||
if (el && "scrollIntoView" in el) {
|
||||
(el as HTMLElement).scrollIntoView({
|
||||
block: "center",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
});
|
||||
const clear = window.setTimeout(() => {
|
||||
setHighlightOrderId(null);
|
||||
highlightHandledRef.current = null;
|
||||
}, 2000);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
window.clearTimeout(clear);
|
||||
};
|
||||
}, [highlightOrderId, mine, unclaimed]);
|
||||
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
// confirmIds === null → no dialog; otherwise the ids that confirming will delete.
|
||||
const [confirmIds, setConfirmIds] = useState<number[] | null>(null);
|
||||
@@ -610,6 +662,7 @@ export default function OrdersIncomingPage() {
|
||||
key={o.id}
|
||||
order={o}
|
||||
meId={meId}
|
||||
highlighted={o.id === highlightOrderId}
|
||||
onActionDone={refetch}
|
||||
selected={validSelected.has(o.id)}
|
||||
onToggleSelected={toggleOne}
|
||||
@@ -649,6 +702,7 @@ export default function OrdersIncomingPage() {
|
||||
key={o.id}
|
||||
order={o}
|
||||
meId={meId}
|
||||
highlighted={o.id === highlightOrderId}
|
||||
onActionDone={refetch}
|
||||
selected={validSelected.has(o.id)}
|
||||
onToggleSelected={toggleOne}
|
||||
|
||||
@@ -0,0 +1,602 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
CalendarClock,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Plus,
|
||||
X,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { DateTimePicker } from "@/components/date-time-picker";
|
||||
|
||||
const API = `${import.meta.env.BASE_URL}api`;
|
||||
|
||||
type PublicRoom = { id: number; nameAr: string; nameEn: string };
|
||||
|
||||
type MeetingType = "internal" | "external";
|
||||
type AttendeeSide = "internal" | "external";
|
||||
type AttendeeRow = { name: string; position: string };
|
||||
|
||||
type FormState = {
|
||||
requesterName: string;
|
||||
title: string;
|
||||
requesterPhone: string;
|
||||
department: string;
|
||||
meetingType: MeetingType;
|
||||
requesterOrg: string;
|
||||
attendeeCount: string;
|
||||
purpose: string;
|
||||
roomId: string;
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
notes: string;
|
||||
website: string; // honeypot
|
||||
};
|
||||
|
||||
const EMPTY: FormState = {
|
||||
requesterName: "",
|
||||
title: "",
|
||||
requesterPhone: "",
|
||||
department: "",
|
||||
meetingType: "internal",
|
||||
requesterOrg: "",
|
||||
attendeeCount: "",
|
||||
purpose: "",
|
||||
roomId: "",
|
||||
startsAt: "",
|
||||
endsAt: "",
|
||||
notes: "",
|
||||
website: "",
|
||||
};
|
||||
|
||||
const EMPTY_ROW: AttendeeRow = { name: "", position: "" };
|
||||
|
||||
function localToIso(v: string): string {
|
||||
return new Date(v).toISOString();
|
||||
}
|
||||
|
||||
export default function ProtocolRequestPage() {
|
||||
// This page is public (no auth). Force RTL Arabic regardless of any
|
||||
// stored language preference so a guest always sees the intended form.
|
||||
useEffect(() => {
|
||||
const prevDir = document.documentElement.dir;
|
||||
const prevLang = document.documentElement.lang;
|
||||
document.documentElement.dir = "rtl";
|
||||
document.documentElement.lang = "ar";
|
||||
return () => {
|
||||
// Restore the app's direction/language so navigating away from this
|
||||
// public page doesn't leave the rest of the app forced into RTL Arabic.
|
||||
document.documentElement.dir = prevDir;
|
||||
document.documentElement.lang = prevLang;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const rooms = useQuery<PublicRoom[]>({
|
||||
queryKey: ["protocol-public", "rooms"],
|
||||
queryFn: async () => {
|
||||
const r = await fetch(`${API}/protocol/public/rooms`);
|
||||
if (!r.ok) throw new Error("rooms");
|
||||
return r.json();
|
||||
},
|
||||
});
|
||||
|
||||
const [form, setForm] = useState<FormState>(EMPTY);
|
||||
// Key attendees ("أبرز الحضور") are two separate lists: من الهيئة (internal)
|
||||
// and خارج الهيئة (external). Each row is a name + position.
|
||||
const [internalAttendees, setInternalAttendees] = useState<AttendeeRow[]>([
|
||||
{ ...EMPTY_ROW },
|
||||
]);
|
||||
const [externalAttendees, setExternalAttendees] = useState<AttendeeRow[]>([
|
||||
{ ...EMPTY_ROW },
|
||||
]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
const [reference, setReference] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const set = <K extends keyof FormState>(k: K, v: FormState[K]) =>
|
||||
setForm((f) => ({ ...f, [k]: v }));
|
||||
|
||||
const updateRow = (
|
||||
side: AttendeeSide,
|
||||
idx: number,
|
||||
field: keyof AttendeeRow,
|
||||
value: string,
|
||||
) => {
|
||||
const setter =
|
||||
side === "internal" ? setInternalAttendees : setExternalAttendees;
|
||||
setter((rows) =>
|
||||
rows.map((r, i) => (i === idx ? { ...r, [field]: value } : r)),
|
||||
);
|
||||
};
|
||||
|
||||
const addRow = (side: AttendeeSide) => {
|
||||
const setter =
|
||||
side === "internal" ? setInternalAttendees : setExternalAttendees;
|
||||
setter((rows) => [...rows, { ...EMPTY_ROW }]);
|
||||
};
|
||||
|
||||
const removeRow = (side: AttendeeSide, idx: number) => {
|
||||
const setter =
|
||||
side === "internal" ? setInternalAttendees : setExternalAttendees;
|
||||
setter((rows) =>
|
||||
rows.length <= 1 ? [{ ...EMPTY_ROW }] : rows.filter((_, i) => i !== idx),
|
||||
);
|
||||
};
|
||||
|
||||
const isExternal = form.meetingType === "external";
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
return (
|
||||
form.requesterName.trim().length > 0 &&
|
||||
form.title.trim().length > 0 &&
|
||||
form.requesterPhone.trim().length >= 3 &&
|
||||
form.roomId !== "" &&
|
||||
form.startsAt !== "" &&
|
||||
form.endsAt !== "" &&
|
||||
new Date(form.startsAt) < new Date(form.endsAt) &&
|
||||
(!isExternal || form.requesterOrg.trim().length > 0)
|
||||
);
|
||||
}, [form, isExternal]);
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (!canSubmit || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const keyAttendees = [
|
||||
...internalAttendees
|
||||
.filter((r) => r.name.trim() !== "")
|
||||
.map((r) => ({
|
||||
name: r.name.trim(),
|
||||
position: r.position.trim(),
|
||||
side: "internal" as const,
|
||||
})),
|
||||
...externalAttendees
|
||||
.filter((r) => r.name.trim() !== "")
|
||||
.map((r) => ({
|
||||
name: r.name.trim(),
|
||||
position: r.position.trim(),
|
||||
side: "external" as const,
|
||||
})),
|
||||
];
|
||||
const res = await fetch(`${API}/protocol/public/booking-requests`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
roomId: Number(form.roomId),
|
||||
title: form.title.trim(),
|
||||
requesterName: form.requesterName.trim(),
|
||||
requesterPhone: form.requesterPhone.trim(),
|
||||
department: form.department.trim() || null,
|
||||
meetingType: form.meetingType,
|
||||
requesterOrg: isExternal ? form.requesterOrg.trim() : null,
|
||||
purpose: form.purpose.trim() || null,
|
||||
attendeeCount:
|
||||
form.attendeeCount.trim() === ""
|
||||
? null
|
||||
: Number(form.attendeeCount),
|
||||
keyAttendees,
|
||||
startsAt: localToIso(form.startsAt),
|
||||
endsAt: localToIso(form.endsAt),
|
||||
notes: form.notes.trim() || null,
|
||||
website: form.website,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
try {
|
||||
const data = await res.json();
|
||||
if (data?.bookingReference && typeof data.bookingReference === "string") {
|
||||
setReference(data.bookingReference);
|
||||
}
|
||||
} catch {
|
||||
// no reference in body — still a success
|
||||
}
|
||||
setDone(true);
|
||||
return;
|
||||
}
|
||||
let msg = "تعذّر إرسال الطلب، يرجى المحاولة مرة أخرى.";
|
||||
try {
|
||||
const data = await res.json();
|
||||
if (data?.error && typeof data.error === "string") msg = data.error;
|
||||
} catch {
|
||||
// keep default message
|
||||
}
|
||||
if (res.status === 429) {
|
||||
msg = "لقد أرسلت طلبات كثيرة، يرجى المحاولة لاحقاً.";
|
||||
}
|
||||
setError(msg);
|
||||
} catch {
|
||||
setError("تعذّر الاتصال بالخادم، تحقق من الاتصال وحاول مجدداً.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setForm(EMPTY);
|
||||
setInternalAttendees([{ ...EMPTY_ROW }]);
|
||||
setExternalAttendees([{ ...EMPTY_ROW }]);
|
||||
setReference(null);
|
||||
setDone(false);
|
||||
};
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<div
|
||||
dir="rtl"
|
||||
className="min-h-screen bg-slate-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<div className="w-full max-w-md bg-white rounded-2xl border border-slate-200 shadow-sm p-8 text-center">
|
||||
<CheckCircle2 className="w-14 h-14 text-emerald-500 mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-slate-800 mb-2">
|
||||
تم استلام طلبك
|
||||
</h1>
|
||||
<p className="text-slate-500 leading-relaxed">
|
||||
سيقوم فريق المراسم بمراجعة طلب الحجز والتواصل معك على رقم الهاتف
|
||||
المُدخل لتأكيد الموعد.
|
||||
</p>
|
||||
{reference && (
|
||||
<div className="mt-5 rounded-xl bg-sky-50 border border-sky-200 px-4 py-4">
|
||||
<p className="text-sm text-slate-500 mb-1">رقم الحجز</p>
|
||||
<p
|
||||
className="text-2xl font-bold tracking-wider text-sky-700"
|
||||
dir="ltr"
|
||||
>
|
||||
{reference}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-2">
|
||||
احتفظ بهذا الرقم لمتابعة حالة طلبك.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Button className="mt-6" variant="outline" onClick={resetForm}>
|
||||
إرسال طلب آخر
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div dir="rtl" className="min-h-screen bg-slate-50 py-8 px-4">
|
||||
<div className="w-full max-w-xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-11 h-11 rounded-xl bg-sky-500/10 text-sky-600 flex items-center justify-center">
|
||||
<CalendarClock className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-slate-800">طلب حجز قاعة</h1>
|
||||
<p className="text-sm text-slate-500">
|
||||
يرجى تعبئة البيانات وسيتواصل معك فريق المراسم للتأكيد.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6 space-y-5"
|
||||
>
|
||||
{/* Honeypot: hidden from real users, catches bots. */}
|
||||
<div className="hidden" aria-hidden="true">
|
||||
<label>
|
||||
Website
|
||||
<input
|
||||
type="text"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
value={form.website}
|
||||
onChange={(e) => set("website", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="requesterName">
|
||||
الاسم <span className="text-rose-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="requesterName"
|
||||
value={form.requesterName}
|
||||
onChange={(e) => set("requesterName", e.target.value)}
|
||||
placeholder="الاسم الثلاثي"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="requesterPhone">
|
||||
رقم الهاتف للتواصل <span className="text-rose-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="requesterPhone"
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
dir="ltr"
|
||||
className="text-right"
|
||||
value={form.requesterPhone}
|
||||
onChange={(e) => set("requesterPhone", e.target.value)}
|
||||
placeholder="05xxxxxxxx"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="room">
|
||||
القاعة <span className="text-rose-500">*</span>
|
||||
</Label>
|
||||
<Select value={form.roomId} onValueChange={(v) => set("roomId", v)}>
|
||||
<SelectTrigger id="room">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
rooms.isLoading ? "جارٍ التحميل…" : "اختر القاعة"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(rooms.data ?? []).map((r) => (
|
||||
<SelectItem key={r.id} value={String(r.id)}>
|
||||
{r.nameAr || r.nameEn}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{rooms.isError && (
|
||||
<p className="text-xs text-rose-500">
|
||||
تعذّر تحميل قائمة القاعات، يرجى تحديث الصفحة.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="department">الإدارة</Label>
|
||||
<Input
|
||||
id="department"
|
||||
value={form.department}
|
||||
onChange={(e) => set("department", e.target.value)}
|
||||
placeholder="الإدارة أو القسم (اختياري)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">
|
||||
عنوان الاجتماع <span className="text-rose-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={form.title}
|
||||
onChange={(e) => set("title", e.target.value)}
|
||||
placeholder="موضوع أو عنوان الاجتماع"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
نوع الاجتماع <span className="text-rose-500">*</span>
|
||||
</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(
|
||||
[
|
||||
{ value: "internal", label: "اجتماع داخلي" },
|
||||
{ value: "external", label: "اجتماع مع جهة خارجية" },
|
||||
] as const
|
||||
).map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => set("meetingType", opt.value)}
|
||||
className={`rounded-lg border px-3 py-2.5 text-sm font-medium transition-colors ${
|
||||
form.meetingType === opt.value
|
||||
? "border-sky-500 bg-sky-50 text-sky-700"
|
||||
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExternal && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="requesterOrg">
|
||||
اسم الجهة <span className="text-rose-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="requesterOrg"
|
||||
value={form.requesterOrg}
|
||||
onChange={(e) => set("requesterOrg", e.target.value)}
|
||||
placeholder="اسم الجهة الخارجية"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="attendeeCount">العدد المتوقع للحضور</Label>
|
||||
<Input
|
||||
id="attendeeCount"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
value={form.attendeeCount}
|
||||
onChange={(e) => set("attendeeCount", e.target.value)}
|
||||
placeholder="العدد المتوقع للحضور (اختياري)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="purpose">الغرض من الاجتماع</Label>
|
||||
<Textarea
|
||||
id="purpose"
|
||||
value={form.purpose}
|
||||
onChange={(e) => set("purpose", e.target.value)}
|
||||
placeholder="اشرح الغرض من الاجتماع (اختياري)"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="startsAt">
|
||||
من <span className="text-rose-500">*</span>
|
||||
</Label>
|
||||
<DateTimePicker
|
||||
id="startsAt"
|
||||
value={form.startsAt}
|
||||
onChange={(v) => set("startsAt", v)}
|
||||
placeholder="اختر تاريخ ووقت البداية"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endsAt">
|
||||
إلى <span className="text-rose-500">*</span>
|
||||
</Label>
|
||||
<DateTimePicker
|
||||
id="endsAt"
|
||||
value={form.endsAt}
|
||||
onChange={(v) => set("endsAt", v)}
|
||||
placeholder="اختر تاريخ ووقت النهاية"
|
||||
minDate={form.startsAt ? new Date(form.startsAt) : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{form.startsAt !== "" &&
|
||||
form.endsAt !== "" &&
|
||||
new Date(form.startsAt) >= new Date(form.endsAt) && (
|
||||
<p className="text-xs text-rose-500 -mt-2">
|
||||
يجب أن يكون وقت البداية قبل وقت النهاية.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>أبرز الحضور</Label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<AttendeeColumn
|
||||
title="من الهيئة"
|
||||
rows={internalAttendees}
|
||||
onChange={(idx, field, value) =>
|
||||
updateRow("internal", idx, field, value)
|
||||
}
|
||||
onAdd={() => addRow("internal")}
|
||||
onRemove={(idx) => removeRow("internal", idx)}
|
||||
/>
|
||||
<AttendeeColumn
|
||||
title="خارج الهيئة"
|
||||
rows={externalAttendees}
|
||||
onChange={(idx, field, value) =>
|
||||
updateRow("external", idx, field, value)
|
||||
}
|
||||
onAdd={() => addRow("external")}
|
||||
onRemove={(idx) => removeRow("external", idx)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="notes">ملاحظات</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
value={form.notes}
|
||||
onChange={(e) => set("notes", e.target.value)}
|
||||
placeholder="أي تفاصيل إضافية (اختياري)"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 rounded-lg bg-rose-50 border border-rose-200 text-rose-700 text-sm px-3 py-3">
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<span>
|
||||
للإحاطة: في حال الحاجة للقاعة سيتم إلغاء الاجتماع أو تعديل موقعه.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-rose-50 border border-rose-200 text-rose-700 text-sm px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={!canSubmit || submitting}
|
||||
>
|
||||
{submitting && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
إرسال الطلب
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AttendeeColumn({
|
||||
title,
|
||||
rows,
|
||||
onChange,
|
||||
onAdd,
|
||||
onRemove,
|
||||
}: {
|
||||
title: string;
|
||||
rows: AttendeeRow[];
|
||||
onChange: (idx: number, field: keyof AttendeeRow, value: string) => void;
|
||||
onAdd: () => void;
|
||||
onRemove: (idx: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 p-3 space-y-3">
|
||||
<p className="text-sm font-semibold text-slate-700">{title}</p>
|
||||
<div className="space-y-3">
|
||||
{rows.map((row, idx) => (
|
||||
<div key={idx} className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={row.name}
|
||||
onChange={(e) => onChange(idx, "name", e.target.value)}
|
||||
placeholder="الاسم"
|
||||
/>
|
||||
{rows.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(idx)}
|
||||
className="shrink-0 text-slate-400 hover:text-rose-500 p-1"
|
||||
aria-label="حذف الصف"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
value={row.position}
|
||||
onChange={(e) => onChange(idx, "position", e.target.value)}
|
||||
placeholder="المنصب"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
className="flex items-center gap-1 text-sm font-medium text-sky-600 hover:text-sky-700"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
إضافة صف
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,10 @@ services:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-tx}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-tx_dev_password}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-tx}
|
||||
# #626: pin container TZ so PG's `now()` (used by audit logs
|
||||
# only — meeting_date/start_time are stored as date/time WITHOUT
|
||||
# timezone) matches the operator's wall clock instead of UTC.
|
||||
TZ: Asia/Riyadh
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
@@ -63,6 +67,13 @@ services:
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 8080
|
||||
# #626: pin container TZ to Riyadh so JS `new Date()` calls in
|
||||
# the meeting scheduler interpret the DB's naive date+time
|
||||
# columns against the operator's local clock. Without this the
|
||||
# container runs in UTC and the 5-minute upcoming-meeting window
|
||||
# is offset by 3 hours, causing reminders to land at the wrong
|
||||
# wall-clock time.
|
||||
TZ: Asia/Riyadh
|
||||
DATABASE_URL: postgres://${POSTGRES_USER:-tx}:${POSTGRES_PASSWORD:-tx_dev_password}@postgres:5432/${POSTGRES_DB:-tx}
|
||||
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET is required — copy .env.docker.example to .env and edit it}
|
||||
LOCAL_STORAGE_SIGNING_SECRET: ${LOCAL_STORAGE_SIGNING_SECRET:-${SESSION_SECRET}}
|
||||
@@ -98,6 +109,12 @@ services:
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- api
|
||||
environment:
|
||||
# #626: pin container TZ for consistency with the api service.
|
||||
# The web image only serves static assets so this is cosmetic
|
||||
# (affects access-log timestamps only), but keeping it aligned
|
||||
# avoids confusion when reading logs side-by-side.
|
||||
TZ: Asia/Riyadh
|
||||
# No host port binding — Caddy is the single public edge.
|
||||
expose:
|
||||
- "80"
|
||||
|
||||
@@ -81,6 +81,48 @@ async function main(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// Backfill booking references BEFORE the schema push enforces the
|
||||
// NOT NULL + unique constraint on protocol_room_bookings.booking_reference.
|
||||
// Without this, pushing the constraint against a populated table (any
|
||||
// pre-existing booking predates the column) would fail. Reference format
|
||||
// mirrors makeBookingReference() in the API: RB-{year}-{pad4(id)}, which
|
||||
// is unique because id is unique. Idempotent: only touches NULL rows.
|
||||
if (await tableExists(client, "protocol_room_bookings")) {
|
||||
const hasColumn = await client.query(
|
||||
`SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'protocol_room_bookings'
|
||||
AND column_name = 'booking_reference'`,
|
||||
);
|
||||
if ((hasColumn.rowCount ?? 0) > 0) {
|
||||
const backfilled = await client.query(
|
||||
`UPDATE protocol_room_bookings
|
||||
SET booking_reference =
|
||||
'RB-' || EXTRACT(YEAR FROM created_at)::int
|
||||
|| '-' || LPAD(id::text, 4, '0')
|
||||
WHERE booking_reference IS NULL`,
|
||||
);
|
||||
const filled = backfilled.rowCount ?? 0;
|
||||
if (filled > 0) {
|
||||
console.log(
|
||||
`[pre-push] Backfilled ${filled} protocol_room_bookings booking_reference value(s).`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
"[pre-push] No protocol_room_bookings booking_reference to backfill.",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
"[pre-push] protocol_room_bookings.booking_reference column not present yet — skipping backfill.",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
"[pre-push] protocol_room_bookings table not present yet — skipping booking_reference backfill.",
|
||||
);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK").catch(() => {});
|
||||
|
||||
@@ -27,6 +27,7 @@ export const BUILTIN_APP_SLUGS = [
|
||||
"executive-meetings",
|
||||
"calendar",
|
||||
"documents",
|
||||
"protocol",
|
||||
] as const;
|
||||
|
||||
export type BuiltinAppSlug = (typeof BUILTIN_APP_SLUGS)[number];
|
||||
|
||||
@@ -73,6 +73,12 @@ export const executiveMeetingsTable = pgTable(
|
||||
// signal about the meeting (urgent / VIP / etc.) rather than a
|
||||
// personal viewing preference.
|
||||
rowColor: varchar("row_color", { length: 16 }),
|
||||
// #635: External meetings (اجتماع خارجي). When true, the row is
|
||||
// tinted red in the schedule grid and is excluded from the
|
||||
// auto-postpone cascade (the alert "Postpone" button is disabled
|
||||
// and `computeCascadeShift` skips it as a follower). Default false
|
||||
// preserves legacy rows as "internal" meetings.
|
||||
isExternal: boolean("is_external").notNull().default(false),
|
||||
createdBy: integer("created_by").references(() => usersTable.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
|
||||
@@ -14,4 +14,5 @@ export * from "./audit-logs";
|
||||
export * from "./role-audit";
|
||||
export * from "./permission-audit";
|
||||
export * from "./executive-meetings";
|
||||
export * from "./protocol";
|
||||
export * from "./push-subscriptions";
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
import {
|
||||
pgTable,
|
||||
serial,
|
||||
integer,
|
||||
varchar,
|
||||
text,
|
||||
timestamp,
|
||||
jsonb,
|
||||
boolean,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { z } from "zod/v4";
|
||||
import { usersTable } from "./users";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public Relations & Protocol module ("العلاقات العامة والمراسم").
|
||||
//
|
||||
// A completely standalone module. Every table is prefixed `protocol_` and has
|
||||
// no foreign keys into the Executive Meetings tables, so the two modules stay
|
||||
// fully independent. Only `usersTable` (the shared account table) is
|
||||
// referenced, and always with `on delete set null` so removing a user never
|
||||
// cascades into protocol history.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Status values for room bookings and gift issuances. The overlap /
|
||||
// conflict-prevention rule only considers "pending" and "approved" rows as
|
||||
// occupying a room; "rejected" and "cancelled" free the slot again.
|
||||
export const PROTOCOL_BOOKING_STATUSES = [
|
||||
"pending",
|
||||
"approved",
|
||||
"rejected",
|
||||
"cancelled",
|
||||
] as const;
|
||||
export type ProtocolBookingStatus =
|
||||
(typeof PROTOCOL_BOOKING_STATUSES)[number];
|
||||
|
||||
// A room booking is either an internal meeting or a meeting with an external
|
||||
// entity. When external, the requester's organization (requesterOrg / اسم
|
||||
// الجهة) is required.
|
||||
export const PROTOCOL_MEETING_TYPES = ["internal", "external"] as const;
|
||||
export type ProtocolMeetingType = (typeof PROTOCOL_MEETING_TYPES)[number];
|
||||
|
||||
// Which "side" a key attendee belongs to: from the authority (من الهيئة) or
|
||||
// from outside it (خارج الهيئة).
|
||||
export const PROTOCOL_ATTENDEE_SIDES = ["internal", "external"] as const;
|
||||
export type ProtocolAttendeeSide = (typeof PROTOCOL_ATTENDEE_SIDES)[number];
|
||||
|
||||
// A notable attendee ("أبرز الحضور") captured on the booking request. Stored
|
||||
// as a JSON array on the booking row.
|
||||
export type ProtocolKeyAttendee = {
|
||||
name: string;
|
||||
position: string;
|
||||
side: ProtocolAttendeeSide;
|
||||
};
|
||||
|
||||
export const PROTOCOL_ISSUE_STATUSES = [
|
||||
"pending",
|
||||
"approved",
|
||||
"rejected",
|
||||
"issued",
|
||||
] as const;
|
||||
export type ProtocolIssueStatus = (typeof PROTOCOL_ISSUE_STATUSES)[number];
|
||||
|
||||
// Gift catalogue kinds: a regular gift or a commemorative shield (درع).
|
||||
export const PROTOCOL_GIFT_KINDS = ["gift", "shield"] as const;
|
||||
export type ProtocolGiftKind = (typeof PROTOCOL_GIFT_KINDS)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rooms
|
||||
// ---------------------------------------------------------------------------
|
||||
export const protocolRoomsTable = pgTable(
|
||||
"protocol_rooms",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
nameAr: varchar("name_ar", { length: 200 }).notNull(),
|
||||
nameEn: varchar("name_en", { length: 200 }).notNull().default(""),
|
||||
capacity: integer("capacity"),
|
||||
location: varchar("location", { length: 300 }),
|
||||
isActive: boolean("is_active").notNull().default(true),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
createdBy: integer("created_by").references(() => usersTable.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date()),
|
||||
},
|
||||
(t) => ({
|
||||
activeIdx: index("protocol_rooms_active_idx").on(t.isActive),
|
||||
}),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Room bookings
|
||||
// ---------------------------------------------------------------------------
|
||||
export const protocolRoomBookingsTable = pgTable(
|
||||
"protocol_room_bookings",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
roomId: integer("room_id")
|
||||
.notNull()
|
||||
.references(() => protocolRoomsTable.id, { onDelete: "cascade" }),
|
||||
title: varchar("title", { length: 500 }).notNull(),
|
||||
requesterName: varchar("requester_name", { length: 300 }),
|
||||
// Guest-supplied contact phone. Only populated for public
|
||||
// (self-service) booking requests; internal bookings leave it null.
|
||||
requesterPhone: varchar("requester_phone", { length: 40 }),
|
||||
// The requester's organization / entity (الجهة). Optional even for
|
||||
// public requests; always null for internal bookings unless set.
|
||||
requesterOrg: varchar("requester_org", { length: 300 }),
|
||||
// Expected number of attendees (العدد المتوقع للحضور). Optional; supplied
|
||||
// by the guest on a public request or by staff on an internal booking.
|
||||
attendeeCount: integer("attendee_count"),
|
||||
// The requester's department / administration (الإدارة). Optional.
|
||||
department: varchar("department", { length: 300 }),
|
||||
// Whether the meeting is internal or with an external entity. When
|
||||
// "external", requesterOrg (اسم الجهة) is required. Defaults to internal.
|
||||
meetingType: varchar("meeting_type", { length: 20 })
|
||||
.notNull()
|
||||
.default("internal"),
|
||||
// Free-text purpose of the meeting (الغرض من الاجتماع). Optional.
|
||||
purpose: text("purpose"),
|
||||
// Notable attendees (أبرز الحضور): array of { name, position, side }.
|
||||
keyAttendees: jsonb("key_attendees")
|
||||
.$type<ProtocolKeyAttendee[]>()
|
||||
.notNull()
|
||||
.default([]),
|
||||
// Human-friendly, unique booking reference (رقم الحجز) shown to the
|
||||
// requester on success and searchable by staff. Generated on insert and
|
||||
// never null — every booking (public or internal) has one.
|
||||
bookingReference: varchar("booking_reference", { length: 40 }).notNull(),
|
||||
// Provenance marker. "internal" for bookings created by authenticated
|
||||
// protocol staff through the app; "public" for guest requests coming in
|
||||
// via the no-login booking-request link. Defaults to "internal" so all
|
||||
// pre-existing rows are treated as internal.
|
||||
source: varchar("source", { length: 20 }).notNull().default("internal"),
|
||||
// Full timestamps so bookings can span any part of a day. The overlap
|
||||
// rule compares [startsAt, endsAt) intervals per room.
|
||||
startsAt: timestamp("starts_at", { withTimezone: true }).notNull(),
|
||||
endsAt: timestamp("ends_at", { withTimezone: true }).notNull(),
|
||||
status: varchar("status", { length: 20 }).notNull().default("pending"),
|
||||
notes: text("notes"),
|
||||
createdBy: integer("created_by").references(() => usersTable.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
approvedBy: integer("approved_by").references(() => usersTable.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
approvedAt: timestamp("approved_at", { withTimezone: true }),
|
||||
rejectionReason: text("rejection_reason"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date()),
|
||||
},
|
||||
(t) => ({
|
||||
roomIdx: index("protocol_bookings_room_idx").on(t.roomId),
|
||||
startIdx: index("protocol_bookings_start_idx").on(t.startsAt),
|
||||
statusIdx: index("protocol_bookings_status_idx").on(t.status),
|
||||
referenceIdx: uniqueIndex("protocol_bookings_reference_idx").on(
|
||||
t.bookingReference,
|
||||
),
|
||||
phoneIdx: index("protocol_bookings_phone_idx").on(t.requesterPhone),
|
||||
}),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// External protocol meetings (اجتماعات ومراسم خارجية) — independent from the
|
||||
// Executive Meetings module's is_external flag.
|
||||
// ---------------------------------------------------------------------------
|
||||
export const protocolExternalMeetingsTable = pgTable(
|
||||
"protocol_external_meetings",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
title: varchar("title", { length: 500 }).notNull(),
|
||||
// The visiting party / external delegation.
|
||||
partyName: varchar("party_name", { length: 300 }),
|
||||
location: varchar("location", { length: 300 }),
|
||||
startsAt: timestamp("starts_at", { withTimezone: true }).notNull(),
|
||||
endsAt: timestamp("ends_at", { withTimezone: true }),
|
||||
// Lifecycle: pending -> approved | rejected, then optionally
|
||||
// completed | cancelled. Mirrors the booking / gift-issue approval flow.
|
||||
status: varchar("status", { length: 20 }).notNull().default("pending"),
|
||||
notes: text("notes"),
|
||||
createdBy: integer("created_by").references(() => usersTable.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
approvedBy: integer("approved_by").references(() => usersTable.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
approvedAt: timestamp("approved_at", { withTimezone: true }),
|
||||
rejectionReason: text("rejection_reason"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date()),
|
||||
},
|
||||
(t) => ({
|
||||
startIdx: index("protocol_ext_meetings_start_idx").on(t.startsAt),
|
||||
statusIdx: index("protocol_ext_meetings_status_idx").on(t.status),
|
||||
}),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gifts & shields catalogue
|
||||
// ---------------------------------------------------------------------------
|
||||
export const protocolGiftsTable = pgTable(
|
||||
"protocol_gifts",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
nameAr: varchar("name_ar", { length: 300 }).notNull(),
|
||||
nameEn: varchar("name_en", { length: 300 }).notNull().default(""),
|
||||
kind: varchar("kind", { length: 20 }).notNull().default("gift"),
|
||||
descriptionAr: text("description_ar"),
|
||||
descriptionEn: text("description_en"),
|
||||
imageUrl: varchar("image_url", { length: 500 }),
|
||||
// Running stock count. Issuing an approved gift decrements this.
|
||||
quantityInStock: integer("quantity_in_stock").notNull().default(0),
|
||||
isActive: boolean("is_active").notNull().default(true),
|
||||
createdBy: integer("created_by").references(() => usersTable.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date()),
|
||||
},
|
||||
(t) => ({
|
||||
kindIdx: index("protocol_gifts_kind_idx").on(t.kind),
|
||||
}),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gift issuances (صرف الهدايا/الدروع)
|
||||
// ---------------------------------------------------------------------------
|
||||
export const protocolGiftIssuesTable = pgTable(
|
||||
"protocol_gift_issues",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
giftId: integer("gift_id")
|
||||
.notNull()
|
||||
.references(() => protocolGiftsTable.id, { onDelete: "restrict" }),
|
||||
recipientName: varchar("recipient_name", { length: 300 }).notNull(),
|
||||
occasion: varchar("occasion", { length: 300 }),
|
||||
quantity: integer("quantity").notNull().default(1),
|
||||
issuedAt: timestamp("issued_at", { withTimezone: true }),
|
||||
status: varchar("status", { length: 20 }).notNull().default("pending"),
|
||||
notes: text("notes"),
|
||||
createdBy: integer("created_by").references(() => usersTable.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
approvedBy: integer("approved_by").references(() => usersTable.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
approvedAt: timestamp("approved_at", { withTimezone: true }),
|
||||
rejectionReason: text("rejection_reason"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date()),
|
||||
},
|
||||
(t) => ({
|
||||
giftIdx: index("protocol_gift_issues_gift_idx").on(t.giftId),
|
||||
statusIdx: index("protocol_gift_issues_status_idx").on(t.status),
|
||||
}),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Visit photo albums (صور الزيارات) — "تصوير" tab.
|
||||
//
|
||||
// An album groups photographs of a visit. It may optionally be linked to an
|
||||
// external protocol meeting (اللقاءات الخارجية); the link is `set null` so
|
||||
// deleting the meeting never removes the album. The album cover is derived at
|
||||
// query time from the first photo (by sortOrder), so there is no circular
|
||||
// album <-> photo foreign key to keep in sync.
|
||||
// ---------------------------------------------------------------------------
|
||||
export const protocolPhotoAlbumsTable = pgTable(
|
||||
"protocol_photo_albums",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
name: varchar("name", { length: 300 }).notNull(),
|
||||
externalMeetingId: integer("external_meeting_id").references(
|
||||
() => protocolExternalMeetingsTable.id,
|
||||
{ onDelete: "set null" },
|
||||
),
|
||||
createdBy: integer("created_by").references(() => usersTable.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date()),
|
||||
},
|
||||
(t) => ({
|
||||
meetingIdx: index("protocol_albums_meeting_idx").on(t.externalMeetingId),
|
||||
}),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Photos inside an album. Each photo references a private object-storage path
|
||||
// (`/objects/<id>`) served through the authorized object endpoint. Deleting an
|
||||
// album cascades to its photos.
|
||||
// ---------------------------------------------------------------------------
|
||||
export const protocolPhotosTable = pgTable(
|
||||
"protocol_photos",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
albumId: integer("album_id")
|
||||
.notNull()
|
||||
.references(() => protocolPhotoAlbumsTable.id, { onDelete: "cascade" }),
|
||||
objectPath: varchar("object_path", { length: 500 }).notNull(),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
uploadedBy: integer("uploaded_by").references(() => usersTable.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
albumIdx: index("protocol_photos_album_idx").on(t.albumId),
|
||||
pathIdx: index("protocol_photos_path_idx").on(t.objectPath),
|
||||
}),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audit log (scoped to the protocol module only)
|
||||
// ---------------------------------------------------------------------------
|
||||
export const protocolAuditLogsTable = pgTable(
|
||||
"protocol_audit_logs",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
action: varchar("action", { length: 100 }).notNull(),
|
||||
entityType: varchar("entity_type", { length: 50 }).notNull(),
|
||||
entityId: integer("entity_id"),
|
||||
oldValue: jsonb("old_value"),
|
||||
newValue: jsonb("new_value"),
|
||||
performedBy: integer("performed_by").references(() => usersTable.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
performedAt: timestamp("performed_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
entityIdx: index("protocol_audit_entity_idx").on(t.entityType, t.entityId),
|
||||
performedAtIdx: index("protocol_audit_performed_at_idx").on(t.performedAt),
|
||||
}),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Insert schemas / row types
|
||||
// ---------------------------------------------------------------------------
|
||||
export const insertProtocolRoomSchema = createInsertSchema(
|
||||
protocolRoomsTable,
|
||||
).omit({ id: true, createdAt: true, updatedAt: true });
|
||||
export type InsertProtocolRoom = z.infer<typeof insertProtocolRoomSchema>;
|
||||
export type ProtocolRoom = typeof protocolRoomsTable.$inferSelect;
|
||||
|
||||
export const insertProtocolRoomBookingSchema = createInsertSchema(
|
||||
protocolRoomBookingsTable,
|
||||
).omit({ id: true, createdAt: true, updatedAt: true });
|
||||
export type InsertProtocolRoomBooking = z.infer<
|
||||
typeof insertProtocolRoomBookingSchema
|
||||
>;
|
||||
export type ProtocolRoomBooking = typeof protocolRoomBookingsTable.$inferSelect;
|
||||
|
||||
export const insertProtocolExternalMeetingSchema = createInsertSchema(
|
||||
protocolExternalMeetingsTable,
|
||||
).omit({ id: true, createdAt: true, updatedAt: true });
|
||||
export type InsertProtocolExternalMeeting = z.infer<
|
||||
typeof insertProtocolExternalMeetingSchema
|
||||
>;
|
||||
export type ProtocolExternalMeeting =
|
||||
typeof protocolExternalMeetingsTable.$inferSelect;
|
||||
|
||||
export const insertProtocolGiftSchema = createInsertSchema(
|
||||
protocolGiftsTable,
|
||||
).omit({ id: true, createdAt: true, updatedAt: true });
|
||||
export type InsertProtocolGift = z.infer<typeof insertProtocolGiftSchema>;
|
||||
export type ProtocolGift = typeof protocolGiftsTable.$inferSelect;
|
||||
|
||||
export const insertProtocolGiftIssueSchema = createInsertSchema(
|
||||
protocolGiftIssuesTable,
|
||||
).omit({ id: true, createdAt: true, updatedAt: true });
|
||||
export type InsertProtocolGiftIssue = z.infer<
|
||||
typeof insertProtocolGiftIssueSchema
|
||||
>;
|
||||
export type ProtocolGiftIssue = typeof protocolGiftIssuesTable.$inferSelect;
|
||||
|
||||
export const insertProtocolPhotoAlbumSchema = createInsertSchema(
|
||||
protocolPhotoAlbumsTable,
|
||||
).omit({ id: true, createdAt: true, updatedAt: true });
|
||||
export type InsertProtocolPhotoAlbum = z.infer<
|
||||
typeof insertProtocolPhotoAlbumSchema
|
||||
>;
|
||||
export type ProtocolPhotoAlbum = typeof protocolPhotoAlbumsTable.$inferSelect;
|
||||
|
||||
export const insertProtocolPhotoSchema = createInsertSchema(
|
||||
protocolPhotosTable,
|
||||
).omit({ id: true, createdAt: true });
|
||||
export type InsertProtocolPhoto = z.infer<typeof insertProtocolPhotoSchema>;
|
||||
export type ProtocolPhoto = typeof protocolPhotosTable.$inferSelect;
|
||||
|
||||
export type ProtocolAuditLog = typeof protocolAuditLogsTable.$inferSelect;
|
||||
Generated
+378
@@ -90,6 +90,9 @@ importers:
|
||||
arabic-persian-reshaper:
|
||||
specifier: 1.0.1
|
||||
version: 1.0.1
|
||||
archiver:
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0
|
||||
bcryptjs:
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3
|
||||
@@ -148,6 +151,9 @@ importers:
|
||||
specifier: 'catalog:'
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@types/archiver':
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0
|
||||
'@types/bcryptjs':
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
@@ -2434,6 +2440,7 @@ packages:
|
||||
'@smithy/core@3.24.1':
|
||||
resolution: {integrity: sha512-3mT7o4qQyUWttYnVK3A0Z/u3Xha3E81tXn32Tz6vjZiUXhBrkEivpw1hBYfh84iFF9CSzkBU9Y1DJ3Q6RQ231g==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
deprecated: Deprecated due to bug in browser bundling instructions https://github.com/smithy-lang/smithy-typescript/issues/2025
|
||||
|
||||
'@smithy/credential-provider-imds@4.3.1':
|
||||
resolution: {integrity: sha512-0S/acwHnqX4WrjXzhdiDRxsG2s9SC0cpPIK9nZ1R6UOHd+j7uL28+4bHu22urbLk2TVw3fkp6na/+fkUt/pLNQ==}
|
||||
@@ -2888,6 +2895,9 @@ packages:
|
||||
'@transloadit/prettier-bytes@0.3.5':
|
||||
resolution: {integrity: sha512-xF4A3d/ZyX2LJWeQZREZQw+qFX4TGQ8bGVP97OLRt6sPO6T0TNHBFTuRHOJh7RNmYOBmQ9MHxpolD9bXihpuVA==}
|
||||
|
||||
'@types/archiver@8.0.0':
|
||||
resolution: {integrity: sha512-YpXPbEuv9+eUIPPQWUPahj3cvs9isWRuF+J4z+KbdYVDO3rWorWQFxUVHnwPu2AgKwvgpki5F2VMX0Xx+mX45A==}
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
||||
|
||||
@@ -2996,6 +3006,9 @@ packages:
|
||||
'@types/react@19.2.14':
|
||||
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
|
||||
|
||||
'@types/readdir-glob@1.1.5':
|
||||
resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==}
|
||||
|
||||
'@types/retry@0.12.2':
|
||||
resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==}
|
||||
|
||||
@@ -3101,6 +3114,10 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
|
||||
abort-controller@3.0.0:
|
||||
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
||||
engines: {node: '>=6.5'}
|
||||
|
||||
accepts@1.3.8:
|
||||
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -3143,6 +3160,10 @@ packages:
|
||||
arabic-persian-reshaper@1.0.1:
|
||||
resolution: {integrity: sha512-VYBjkhz6o4W1Xt4mD2LAReljJpLSw5CUZMqSBDIQRvFgUSlTKEYghapgBWvkeMWF4W+KF3Fm+/z8EywJU4PBeg==}
|
||||
|
||||
archiver@8.0.0:
|
||||
resolution: {integrity: sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
argparse@2.0.1:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
@@ -3153,13 +3174,69 @@ packages:
|
||||
asn1.js@5.4.1:
|
||||
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
|
||||
|
||||
async@3.2.6:
|
||||
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
|
||||
|
||||
atomic-sleep@1.0.0:
|
||||
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
b4a@1.8.1:
|
||||
resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==}
|
||||
peerDependencies:
|
||||
react-native-b4a: '*'
|
||||
peerDependenciesMeta:
|
||||
react-native-b4a:
|
||||
optional: true
|
||||
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
balanced-match@4.0.4:
|
||||
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
bare-events@2.9.1:
|
||||
resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==}
|
||||
peerDependencies:
|
||||
bare-abort-controller: '*'
|
||||
peerDependenciesMeta:
|
||||
bare-abort-controller:
|
||||
optional: true
|
||||
|
||||
bare-fs@4.7.3:
|
||||
resolution: {integrity: sha512-xRgplks8SvcKkdlv2M6Z2LZmRsmqd+x0nXXGXeMEjwdibj1HSDrlnqBRLeYdMvsgCox7Bq0e+DHwfczOfsn6IA==}
|
||||
engines: {bare: '>=1.16.0'}
|
||||
peerDependencies:
|
||||
bare-buffer: '*'
|
||||
peerDependenciesMeta:
|
||||
bare-buffer:
|
||||
optional: true
|
||||
|
||||
bare-os@3.9.3:
|
||||
resolution: {integrity: sha512-fF4Q7QsyKVF5Rj0qvI8BgUNjqzC2JvQlpTaPLjVJVxYVUX5Zr9un+y3w1HmA4nNKdFmRBT8z/WmrjvXzXVerKQ==}
|
||||
engines: {bare: '>=1.14.0'}
|
||||
|
||||
bare-path@3.0.1:
|
||||
resolution: {integrity: sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==}
|
||||
|
||||
bare-stream@2.13.3:
|
||||
resolution: {integrity: sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==}
|
||||
peerDependencies:
|
||||
bare-abort-controller: '*'
|
||||
bare-buffer: '*'
|
||||
bare-events: '*'
|
||||
peerDependenciesMeta:
|
||||
bare-abort-controller:
|
||||
optional: true
|
||||
bare-buffer:
|
||||
optional: true
|
||||
bare-events:
|
||||
optional: true
|
||||
|
||||
bare-url@2.4.5:
|
||||
resolution: {integrity: sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==}
|
||||
|
||||
base64-js@0.0.8:
|
||||
resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3196,6 +3273,10 @@ packages:
|
||||
brace-expansion@2.0.2:
|
||||
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
|
||||
|
||||
brace-expansion@5.0.7:
|
||||
resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
braces@3.0.3:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -3211,12 +3292,19 @@ packages:
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
hasBin: true
|
||||
|
||||
buffer-crc32@1.0.0:
|
||||
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
buffer-equal-constant-time@1.0.1:
|
||||
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
buffer@6.0.3:
|
||||
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
||||
|
||||
bytes@3.1.2:
|
||||
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -3270,6 +3358,10 @@ packages:
|
||||
compare-versions@6.1.1:
|
||||
resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==}
|
||||
|
||||
compress-commons@7.0.1:
|
||||
resolution: {integrity: sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
connect-pg-simple@10.0.0:
|
||||
resolution: {integrity: sha512-pBGVazlqiMrackzCr0eKhn4LO5trJXsOX0nQoey9wCOayh80MYtThCbq8eoLsjpiWgiok/h+1/uti9/2/Una8A==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=22.0.0}
|
||||
@@ -3303,10 +3395,22 @@ packages:
|
||||
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
core-util-is@1.0.3:
|
||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||
|
||||
cors@2.8.6:
|
||||
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
crc-32@1.2.2:
|
||||
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
|
||||
engines: {node: '>=0.8'}
|
||||
hasBin: true
|
||||
|
||||
crc32-stream@7.0.1:
|
||||
resolution: {integrity: sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -3662,12 +3766,23 @@ packages:
|
||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
event-target-shim@5.0.1:
|
||||
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
eventemitter3@4.0.7:
|
||||
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
|
||||
|
||||
eventemitter3@5.0.4:
|
||||
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||
|
||||
events-universal@1.0.1:
|
||||
resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
|
||||
|
||||
events@3.3.0:
|
||||
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||
engines: {node: '>=0.8.x'}
|
||||
|
||||
execa@9.6.1:
|
||||
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
|
||||
engines: {node: ^18.19.0 || >=20.5.0}
|
||||
@@ -3699,6 +3814,9 @@ packages:
|
||||
resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
fast-fifo@1.3.2:
|
||||
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
|
||||
|
||||
fast-glob@3.3.3:
|
||||
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
|
||||
engines: {node: '>=8.6.0'}
|
||||
@@ -3873,6 +3991,9 @@ packages:
|
||||
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
ieee754@1.2.1:
|
||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||
|
||||
ignore@7.0.5:
|
||||
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
||||
engines: {node: '>= 4'}
|
||||
@@ -3937,6 +4058,9 @@ packages:
|
||||
resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
isarray@1.0.0:
|
||||
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
@@ -3984,6 +4108,10 @@ packages:
|
||||
jws@4.0.1:
|
||||
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
|
||||
|
||||
lazystream@1.0.1:
|
||||
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
|
||||
engines: {node: '>= 0.6.3'}
|
||||
|
||||
leven@4.1.0:
|
||||
resolution: {integrity: sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
@@ -4145,6 +4273,10 @@ packages:
|
||||
minimalistic-assert@1.0.1:
|
||||
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
|
||||
|
||||
minimatch@10.2.5:
|
||||
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
minimatch@9.0.9:
|
||||
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
@@ -4201,6 +4333,10 @@ packages:
|
||||
resolution: {integrity: sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
normalize-path@3.0.0:
|
||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
npm-run-path@6.0.0:
|
||||
resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4417,9 +4553,16 @@ packages:
|
||||
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
process-nextick-args@2.0.1:
|
||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||
|
||||
process-warning@5.0.0:
|
||||
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
|
||||
|
||||
process@0.11.10:
|
||||
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
|
||||
engines: {node: '>= 0.6.0'}
|
||||
|
||||
prop-types@15.8.1:
|
||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||
|
||||
@@ -4592,6 +4735,17 @@ packages:
|
||||
resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
readable-stream@2.3.8:
|
||||
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||
|
||||
readable-stream@4.7.0:
|
||||
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
|
||||
readdir-glob@3.0.0:
|
||||
resolution: {integrity: sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
readdirp@4.1.2:
|
||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||
engines: {node: '>= 14.18.0'}
|
||||
@@ -4610,6 +4764,7 @@ packages:
|
||||
recharts@2.15.4:
|
||||
resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==}
|
||||
engines: {node: '>=14'}
|
||||
deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide
|
||||
peerDependencies:
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0
|
||||
@@ -4654,6 +4809,9 @@ packages:
|
||||
run-parallel@1.2.0:
|
||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||
|
||||
safe-buffer@5.1.2:
|
||||
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
|
||||
|
||||
safe-buffer@5.2.1:
|
||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||
|
||||
@@ -4771,10 +4929,19 @@ packages:
|
||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
streamx@2.28.0:
|
||||
resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==}
|
||||
|
||||
string-argv@0.3.2:
|
||||
resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==}
|
||||
engines: {node: '>=0.6.19'}
|
||||
|
||||
string_decoder@1.1.1:
|
||||
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
|
||||
|
||||
string_decoder@1.3.0:
|
||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -4805,6 +4972,15 @@ packages:
|
||||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tar-stream@3.2.0:
|
||||
resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==}
|
||||
|
||||
teex@1.0.1:
|
||||
resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==}
|
||||
|
||||
text-decoder@1.2.7:
|
||||
resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
|
||||
|
||||
thread-stream@3.1.0:
|
||||
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
|
||||
|
||||
@@ -4829,6 +5005,7 @@ packages:
|
||||
tsconfck@3.1.6:
|
||||
resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==}
|
||||
engines: {node: ^18 || >=20}
|
||||
deprecated: unmaintained
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
typescript: ^5.0.0
|
||||
@@ -5058,6 +5235,10 @@ packages:
|
||||
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
zip-stream@7.0.5:
|
||||
resolution: {integrity: sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
zod@3.25.76:
|
||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||
|
||||
@@ -7417,6 +7598,11 @@ snapshots:
|
||||
|
||||
'@transloadit/prettier-bytes@0.3.5': {}
|
||||
|
||||
'@types/archiver@8.0.0':
|
||||
dependencies:
|
||||
'@types/node': 25.3.5
|
||||
'@types/readdir-glob': 1.1.5
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.0
|
||||
@@ -7548,6 +7734,10 @@ snapshots:
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
|
||||
'@types/readdir-glob@1.1.5':
|
||||
dependencies:
|
||||
'@types/node': 25.3.5
|
||||
|
||||
'@types/retry@0.12.2': {}
|
||||
|
||||
'@types/sanitize-html@2.16.1':
|
||||
@@ -7673,6 +7863,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
abort-controller@3.0.0:
|
||||
dependencies:
|
||||
event-target-shim: 5.0.1
|
||||
|
||||
accepts@1.3.8:
|
||||
dependencies:
|
||||
mime-types: 2.1.35
|
||||
@@ -7708,6 +7902,22 @@ snapshots:
|
||||
|
||||
arabic-persian-reshaper@1.0.1: {}
|
||||
|
||||
archiver@8.0.0:
|
||||
dependencies:
|
||||
async: 3.2.6
|
||||
buffer-crc32: 1.0.0
|
||||
is-stream: 4.0.1
|
||||
lazystream: 1.0.1
|
||||
normalize-path: 3.0.0
|
||||
readable-stream: 4.7.0
|
||||
readdir-glob: 3.0.0
|
||||
tar-stream: 3.2.0
|
||||
zip-stream: 7.0.5
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- bare-buffer
|
||||
- react-native-b4a
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
@@ -7721,10 +7931,49 @@ snapshots:
|
||||
minimalistic-assert: 1.0.1
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
async@3.2.6: {}
|
||||
|
||||
atomic-sleep@1.0.0: {}
|
||||
|
||||
b4a@1.8.1: {}
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
balanced-match@4.0.4: {}
|
||||
|
||||
bare-events@2.9.1: {}
|
||||
|
||||
bare-fs@4.7.3:
|
||||
dependencies:
|
||||
bare-events: 2.9.1
|
||||
bare-path: 3.0.1
|
||||
bare-stream: 2.13.3(bare-events@2.9.1)
|
||||
bare-url: 2.4.5
|
||||
fast-fifo: 1.3.2
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- react-native-b4a
|
||||
|
||||
bare-os@3.9.3: {}
|
||||
|
||||
bare-path@3.0.1:
|
||||
dependencies:
|
||||
bare-os: 3.9.3
|
||||
|
||||
bare-stream@2.13.3(bare-events@2.9.1):
|
||||
dependencies:
|
||||
b4a: 1.8.1
|
||||
streamx: 2.28.0
|
||||
teex: 1.0.1
|
||||
optionalDependencies:
|
||||
bare-events: 2.9.1
|
||||
transitivePeerDependencies:
|
||||
- react-native-b4a
|
||||
|
||||
bare-url@2.4.5:
|
||||
dependencies:
|
||||
bare-path: 3.0.1
|
||||
|
||||
base64-js@0.0.8: {}
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
@@ -7761,6 +8010,10 @@ snapshots:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
||||
brace-expansion@5.0.7:
|
||||
dependencies:
|
||||
balanced-match: 4.0.4
|
||||
|
||||
braces@3.0.3:
|
||||
dependencies:
|
||||
fill-range: 7.1.1
|
||||
@@ -7781,10 +8034,17 @@ snapshots:
|
||||
node-releases: 2.0.36
|
||||
update-browserslist-db: 1.2.3(browserslist@4.28.1)
|
||||
|
||||
buffer-crc32@1.0.0: {}
|
||||
|
||||
buffer-equal-constant-time@1.0.1: {}
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
buffer@6.0.3:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
ieee754: 1.2.1
|
||||
|
||||
bytes@3.1.2: {}
|
||||
|
||||
call-bind-apply-helpers@1.0.2:
|
||||
@@ -7835,6 +8095,14 @@ snapshots:
|
||||
|
||||
compare-versions@6.1.1: {}
|
||||
|
||||
compress-commons@7.0.1:
|
||||
dependencies:
|
||||
crc-32: 1.2.2
|
||||
crc32-stream: 7.0.1
|
||||
is-stream: 4.0.1
|
||||
normalize-path: 3.0.0
|
||||
readable-stream: 4.7.0
|
||||
|
||||
connect-pg-simple@10.0.0:
|
||||
dependencies:
|
||||
pg: 8.20.0
|
||||
@@ -7860,11 +8128,20 @@ snapshots:
|
||||
|
||||
cookie@0.7.2: {}
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
cors@2.8.6:
|
||||
dependencies:
|
||||
object-assign: 4.1.1
|
||||
vary: 1.1.2
|
||||
|
||||
crc-32@1.2.2: {}
|
||||
|
||||
crc32-stream@7.0.1:
|
||||
dependencies:
|
||||
crc-32: 1.2.2
|
||||
readable-stream: 4.7.0
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
@@ -8182,10 +8459,20 @@ snapshots:
|
||||
|
||||
etag@1.8.1: {}
|
||||
|
||||
event-target-shim@5.0.1: {}
|
||||
|
||||
eventemitter3@4.0.7: {}
|
||||
|
||||
eventemitter3@5.0.4: {}
|
||||
|
||||
events-universal@1.0.1:
|
||||
dependencies:
|
||||
bare-events: 2.9.1
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
|
||||
events@3.3.0: {}
|
||||
|
||||
execa@9.6.1:
|
||||
dependencies:
|
||||
'@sindresorhus/merge-streams': 4.0.0
|
||||
@@ -8260,6 +8547,8 @@ snapshots:
|
||||
|
||||
fast-equals@5.4.0: {}
|
||||
|
||||
fast-fifo@1.3.2: {}
|
||||
|
||||
fast-glob@3.3.3:
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
@@ -8452,6 +8741,8 @@ snapshots:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
ieee754@1.2.1: {}
|
||||
|
||||
ignore@7.0.5: {}
|
||||
|
||||
inherits@2.0.4: {}
|
||||
@@ -8489,6 +8780,8 @@ snapshots:
|
||||
|
||||
is-unicode-supported@2.1.0: {}
|
||||
|
||||
isarray@1.0.0: {}
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
jiti@2.6.1: {}
|
||||
@@ -8528,6 +8821,10 @@ snapshots:
|
||||
jwa: 2.0.1
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
lazystream@1.0.1:
|
||||
dependencies:
|
||||
readable-stream: 2.3.8
|
||||
|
||||
leven@4.1.0: {}
|
||||
|
||||
lightningcss-android-arm64@1.31.1:
|
||||
@@ -8656,6 +8953,10 @@ snapshots:
|
||||
|
||||
minimalistic-assert@1.0.1: {}
|
||||
|
||||
minimatch@10.2.5:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.7
|
||||
|
||||
minimatch@9.0.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.2
|
||||
@@ -8693,6 +8994,8 @@ snapshots:
|
||||
|
||||
nodemailer@8.0.7: {}
|
||||
|
||||
normalize-path@3.0.0: {}
|
||||
|
||||
npm-run-path@6.0.0:
|
||||
dependencies:
|
||||
path-key: 4.0.0
|
||||
@@ -8935,8 +9238,12 @@ snapshots:
|
||||
dependencies:
|
||||
parse-ms: 4.0.0
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
|
||||
process-warning@5.0.0: {}
|
||||
|
||||
process@0.11.10: {}
|
||||
|
||||
prop-types@15.8.1:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
@@ -9132,6 +9439,28 @@ snapshots:
|
||||
|
||||
react@19.1.0: {}
|
||||
|
||||
readable-stream@2.3.8:
|
||||
dependencies:
|
||||
core-util-is: 1.0.3
|
||||
inherits: 2.0.4
|
||||
isarray: 1.0.0
|
||||
process-nextick-args: 2.0.1
|
||||
safe-buffer: 5.1.2
|
||||
string_decoder: 1.1.1
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
readable-stream@4.7.0:
|
||||
dependencies:
|
||||
abort-controller: 3.0.0
|
||||
buffer: 6.0.3
|
||||
events: 3.3.0
|
||||
process: 0.11.10
|
||||
string_decoder: 1.3.0
|
||||
|
||||
readdir-glob@3.0.0:
|
||||
dependencies:
|
||||
minimatch: 10.2.5
|
||||
|
||||
readdirp@4.1.2: {}
|
||||
|
||||
readdirp@5.0.0: {}
|
||||
@@ -9216,6 +9545,8 @@ snapshots:
|
||||
dependencies:
|
||||
queue-microtask: 1.2.3
|
||||
|
||||
safe-buffer@5.1.2: {}
|
||||
|
||||
safe-buffer@5.2.1: {}
|
||||
|
||||
safe-stable-stringify@2.5.0: {}
|
||||
@@ -9369,8 +9700,25 @@ snapshots:
|
||||
|
||||
statuses@2.0.2: {}
|
||||
|
||||
streamx@2.28.0:
|
||||
dependencies:
|
||||
events-universal: 1.0.1
|
||||
fast-fifo: 1.3.2
|
||||
text-decoder: 1.2.7
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- react-native-b4a
|
||||
|
||||
string-argv@0.3.2: {}
|
||||
|
||||
string_decoder@1.1.1:
|
||||
dependencies:
|
||||
safe-buffer: 5.1.2
|
||||
|
||||
string_decoder@1.3.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
dependencies:
|
||||
ansi-regex: 5.0.1
|
||||
@@ -9391,6 +9739,30 @@ snapshots:
|
||||
|
||||
tapable@2.3.0: {}
|
||||
|
||||
tar-stream@3.2.0:
|
||||
dependencies:
|
||||
b4a: 1.8.1
|
||||
bare-fs: 4.7.3
|
||||
fast-fifo: 1.3.2
|
||||
streamx: 2.28.0
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- bare-buffer
|
||||
- react-native-b4a
|
||||
|
||||
teex@1.0.1:
|
||||
dependencies:
|
||||
streamx: 2.28.0
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- react-native-b4a
|
||||
|
||||
text-decoder@1.2.7:
|
||||
dependencies:
|
||||
b4a: 1.8.1
|
||||
transitivePeerDependencies:
|
||||
- react-native-b4a
|
||||
|
||||
thread-stream@3.1.0:
|
||||
dependencies:
|
||||
real-require: 0.2.0
|
||||
@@ -9590,6 +9962,12 @@ snapshots:
|
||||
|
||||
yoctocolors@2.1.2: {}
|
||||
|
||||
zip-stream@7.0.5:
|
||||
dependencies:
|
||||
compress-commons: 7.0.1
|
||||
normalize-path: 3.0.0
|
||||
readable-stream: 4.7.0
|
||||
|
||||
zod@3.25.76: {}
|
||||
|
||||
zod@4.3.6: {}
|
||||
|
||||
+206
-1
@@ -18,8 +18,9 @@ import {
|
||||
executiveMeetingsTable,
|
||||
executiveMeetingAttendeesTable,
|
||||
executiveMeetingNotificationsTable,
|
||||
protocolRoomsTable,
|
||||
} from "@workspace/db";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { eq, inArray, sql } from "drizzle-orm";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
async function main() {
|
||||
@@ -327,6 +328,19 @@ async function main() {
|
||||
isSystem: false,
|
||||
sortOrder: 7,
|
||||
},
|
||||
{
|
||||
slug: "protocol",
|
||||
nameAr: "العلاقات العامة والمراسم",
|
||||
nameEn: "Public Relations & Protocol",
|
||||
descriptionAr: "حجز القاعات والاجتماعات الخارجية والهدايا والدروع",
|
||||
descriptionEn: "Room bookings, external meetings, gifts and shields",
|
||||
iconName: "Handshake",
|
||||
route: "/protocol",
|
||||
color: "#0ea5e9",
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
sortOrder: 9,
|
||||
},
|
||||
];
|
||||
|
||||
// Drift guard: every built-in slug (those with hardcoded routes in the
|
||||
@@ -345,6 +359,7 @@ async function main() {
|
||||
"executive-meetings": "/meetings",
|
||||
calendar: "/calendar",
|
||||
documents: "/documents",
|
||||
protocol: "/protocol",
|
||||
};
|
||||
for (const a of apps) {
|
||||
if ((BUILTIN_APP_SLUGS as readonly string[]).includes(a.slug)) {
|
||||
@@ -763,6 +778,196 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Public Relations & Protocol: roles.
|
||||
const protocolRoles = [
|
||||
{
|
||||
name: "protocol_super_admin",
|
||||
descriptionAr: "المدير العام للعلاقات العامة والمراسم",
|
||||
descriptionEn: "Protocol Super Admin",
|
||||
isSystem: 1,
|
||||
},
|
||||
{
|
||||
name: "protocol_pr_manager",
|
||||
descriptionAr: "مدير العلاقات العامة",
|
||||
descriptionEn: "PR Manager",
|
||||
isSystem: 1,
|
||||
},
|
||||
{
|
||||
name: "protocol_officer",
|
||||
descriptionAr: "موظف المراسم",
|
||||
descriptionEn: "Protocol Officer",
|
||||
isSystem: 1,
|
||||
},
|
||||
{
|
||||
name: "protocol_requester",
|
||||
descriptionAr: "مقدم الطلب",
|
||||
descriptionEn: "Requester",
|
||||
isSystem: 1,
|
||||
},
|
||||
{
|
||||
name: "protocol_viewer",
|
||||
descriptionAr: "مشاهد العلاقات العامة والمراسم",
|
||||
descriptionEn: "Protocol Viewer",
|
||||
isSystem: 1,
|
||||
},
|
||||
];
|
||||
await db.insert(rolesTable).values(protocolRoles).onConflictDoNothing();
|
||||
console.log("Protocol roles created");
|
||||
|
||||
// Scoped protocol permissions. `protocol.access` also gates the home-screen
|
||||
// tile (via app_permissions); the rest guard individual route capabilities.
|
||||
// Route middleware authorizes by these permissions, never by role name, so
|
||||
// an admin can delegate any capability by granting the permission.
|
||||
const protocolPermissions = [
|
||||
{
|
||||
name: "protocol.access",
|
||||
descriptionAr: "الوصول إلى وحدة العلاقات العامة والمراسم",
|
||||
descriptionEn: "Access the Public Relations & Protocol module",
|
||||
},
|
||||
{
|
||||
name: "protocol.request",
|
||||
descriptionAr: "تقديم طلبات الحجز وإصدار الدروع",
|
||||
descriptionEn: "Submit protocol booking and gift-issue requests",
|
||||
},
|
||||
{
|
||||
name: "protocol.mutate",
|
||||
descriptionAr: "إنشاء وتعديل سجلات العلاقات العامة والمراسم",
|
||||
descriptionEn: "Create and edit protocol records",
|
||||
},
|
||||
{
|
||||
name: "protocol.approve",
|
||||
descriptionAr: "اعتماد أو رفض الحجوزات وإصدارات الدروع",
|
||||
descriptionEn: "Approve or reject protocol bookings and gift issues",
|
||||
},
|
||||
{
|
||||
name: "protocol.rooms.manage",
|
||||
descriptionAr: "إدارة سجل القاعات",
|
||||
descriptionEn: "Manage the protocol rooms registry",
|
||||
},
|
||||
{
|
||||
name: "protocol.audit.read",
|
||||
descriptionAr: "عرض سجل تدقيق العلاقات العامة والمراسم",
|
||||
descriptionEn: "View the protocol audit log",
|
||||
},
|
||||
];
|
||||
await db
|
||||
.insert(permissionsTable)
|
||||
.values(protocolPermissions)
|
||||
.onConflictDoNothing();
|
||||
|
||||
// Map each protocol permission to the roles that should hold it. Capabilities
|
||||
// are additive: super admin holds everything, viewer only reads.
|
||||
const protocolRolePermMap: Record<string, string[]> = {
|
||||
"protocol.access": [
|
||||
"admin",
|
||||
"protocol_super_admin",
|
||||
"protocol_pr_manager",
|
||||
"protocol_officer",
|
||||
"protocol_requester",
|
||||
"protocol_viewer",
|
||||
],
|
||||
"protocol.request": [
|
||||
"admin",
|
||||
"protocol_super_admin",
|
||||
"protocol_pr_manager",
|
||||
"protocol_officer",
|
||||
"protocol_requester",
|
||||
],
|
||||
"protocol.mutate": [
|
||||
"admin",
|
||||
"protocol_super_admin",
|
||||
"protocol_pr_manager",
|
||||
"protocol_officer",
|
||||
],
|
||||
"protocol.approve": [
|
||||
"admin",
|
||||
"protocol_super_admin",
|
||||
"protocol_pr_manager",
|
||||
],
|
||||
"protocol.rooms.manage": ["admin", "protocol_super_admin"],
|
||||
"protocol.audit.read": [
|
||||
"admin",
|
||||
"protocol_super_admin",
|
||||
"protocol_pr_manager",
|
||||
],
|
||||
};
|
||||
|
||||
const allRolesForProtocol = await db.select().from(rolesTable);
|
||||
const roleIdByName = new Map(allRolesForProtocol.map((r) => [r.name, r.id]));
|
||||
const allProtocolPerms = await db
|
||||
.select()
|
||||
.from(permissionsTable)
|
||||
.where(
|
||||
inArray(
|
||||
permissionsTable.name,
|
||||
protocolPermissions.map((p) => p.name),
|
||||
),
|
||||
);
|
||||
const permIdByName = new Map(allProtocolPerms.map((p) => [p.name, p.id]));
|
||||
|
||||
const rolePermValues: { roleId: number; permissionId: number }[] = [];
|
||||
for (const [permName, roleNames] of Object.entries(protocolRolePermMap)) {
|
||||
const permId = permIdByName.get(permName);
|
||||
if (permId === undefined) continue;
|
||||
for (const roleName of roleNames) {
|
||||
const roleId = roleIdByName.get(roleName);
|
||||
if (roleId === undefined) continue;
|
||||
rolePermValues.push({ roleId, permissionId: permId });
|
||||
}
|
||||
}
|
||||
if (rolePermValues.length > 0) {
|
||||
await db
|
||||
.insert(rolePermissionsTable)
|
||||
.values(rolePermValues)
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
console.log("Protocol scoped permissions mapped to roles");
|
||||
|
||||
const protocolAccessPermId = permIdByName.get("protocol.access");
|
||||
if (protocolAccessPermId !== undefined) {
|
||||
const [protocolApp] = await db
|
||||
.select()
|
||||
.from(appsTable)
|
||||
.where(eq(appsTable.slug, "protocol"));
|
||||
if (protocolApp) {
|
||||
await db
|
||||
.insert(appPermissionsTable)
|
||||
.values({ appId: protocolApp.id, permissionId: protocolAccessPermId })
|
||||
.onConflictDoNothing();
|
||||
console.log(
|
||||
"App permissions set: protocol app restricted to protocol.access permission",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Public Relations & Protocol: three default meeting rooms. Only seeded
|
||||
// when the table is empty so admins can rename/remove them freely without
|
||||
// re-seeds bringing them back.
|
||||
const existingRooms = await db
|
||||
.select({ id: protocolRoomsTable.id })
|
||||
.from(protocolRoomsTable)
|
||||
.limit(1);
|
||||
if (existingRooms.length === 0) {
|
||||
await db.insert(protocolRoomsTable).values([
|
||||
{
|
||||
nameAr: "قاعة الاجتماعات الرئيسية",
|
||||
nameEn: "Main Meeting Hall",
|
||||
sortOrder: 1,
|
||||
},
|
||||
{
|
||||
nameAr: "قاعة كبار الزوار",
|
||||
nameEn: "VIP Guests Hall",
|
||||
sortOrder: 2,
|
||||
},
|
||||
{
|
||||
nameAr: "قاعة الاجتماعات التنفيذية",
|
||||
nameEn: "Executive Meetings Hall",
|
||||
sortOrder: 3,
|
||||
},
|
||||
]);
|
||||
console.log("Protocol default rooms created");
|
||||
}
|
||||
|
||||
// Executive meetings: sample data for today (idempotent per-date).
|
||||
//
|
||||
// Opt-in only — a vanilla self-hosted install should start with an
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-command helper: merge the Gitea server's changes into this Repl's work and push.
|
||||
# Run from the Replit Shell with: bash sync-to-gitea.sh
|
||||
set -uo pipefail
|
||||
|
||||
REMOTE="origin"
|
||||
BRANCH="main"
|
||||
|
||||
echo "==> Checking GITEA_TOKEN secret is available..."
|
||||
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||
echo "ERROR: GITEA_TOKEN is not set in this shell. Cannot authenticate to Gitea."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Preparing credential helper (token never printed)..."
|
||||
cat > /tmp/ap.sh <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
case "$1" in
|
||||
*Username*) echo "rafraa" ;;
|
||||
*) printf '%s' "$GITEA_TOKEN" ;;
|
||||
esac
|
||||
EOF
|
||||
chmod +x /tmp/ap.sh
|
||||
export GIT_ASKPASS=/tmp/ap.sh GIT_TERMINAL_PROMPT=0
|
||||
|
||||
echo "==> Current branch:"
|
||||
git rev-parse --abbrev-ref HEAD
|
||||
|
||||
echo "==> Fetching $REMOTE/$BRANCH ..."
|
||||
if ! git fetch "$REMOTE" "$BRANCH"; then
|
||||
echo "ERROR: fetch failed. The Gitea server may be unreachable right now. Try again in a moment."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Merging $REMOTE/$BRANCH into local $BRANCH (keeping both sides)..."
|
||||
if ! git merge "$REMOTE/$BRANCH" -m "Merge Gitea server changes with Replit work"; then
|
||||
echo ""
|
||||
echo "MERGE CONFLICT: the merge could not finish automatically."
|
||||
echo "Nothing was pushed. Copy everything above and send it to the assistant to resolve."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Pushing local $BRANCH to $REMOTE ..."
|
||||
if ! git push "$REMOTE" "$BRANCH"; then
|
||||
echo "ERROR: push failed. Copy everything above and send it to the assistant."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "==> SUCCESS. Verifying remote now matches local..."
|
||||
LOCAL=$(git rev-parse "$BRANCH")
|
||||
REMOTE_SHA=$(git ls-remote "$REMOTE" -h "refs/heads/$BRANCH" | awk '{print $1}')
|
||||
echo " local $BRANCH = $LOCAL"
|
||||
echo " server $BRANCH = $REMOTE_SHA"
|
||||
if [ "$LOCAL" = "$REMOTE_SHA" ]; then
|
||||
echo " MATCH — your work is now on Gitea."
|
||||
else
|
||||
echo " WARNING: they do not match yet. Send this output to the assistant."
|
||||
fi
|
||||
Reference in New Issue
Block a user