mirror of
https://github.com/temetro/temetro.git
synced 2026-08-05 08:27:41 +00:00
Merge pull request #3 from temetro/feature/clinical-fixes-and-chat
Feature/clinical fixes and chat
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "tasks" ADD COLUMN "status" text DEFAULT 'todo' NOT NULL;
|
||||
--> statement-breakpoint
|
||||
UPDATE "tasks" SET "status" = 'done' WHERE "done" = true;
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE "org_ai_policy" (
|
||||
"organization_id" text PRIMARY KEY NOT NULL,
|
||||
"ai_enabled" boolean DEFAULT true NOT NULL,
|
||||
"disabled_for_employees" boolean DEFAULT false NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "org_ai_policy" ADD CONSTRAINT "org_ai_policy_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE "message_attachments" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"uploader_id" text,
|
||||
"file_name" text NOT NULL,
|
||||
"mime_type" text NOT NULL,
|
||||
"size" integer NOT NULL,
|
||||
"data" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "messages" ALTER COLUMN "body" SET DEFAULT '';--> statement-breakpoint
|
||||
ALTER TABLE "messages" ADD COLUMN "attachments" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "message_attachments" ADD CONSTRAINT "message_attachments_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "message_attachments" ADD CONSTRAINT "message_attachments_uploader_id_user_id_fk" FOREIGN KEY ("uploader_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "message_attachments_org_idx" ON "message_attachments" USING btree ("organization_id");
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -120,6 +120,27 @@
|
||||
"when": 1781464772532,
|
||||
"tag": "0016_past_maestro",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1781537419038,
|
||||
"tag": "0017_wealthy_northstar",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1781537902724,
|
||||
"tag": "0018_clean_doctor_strange",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1781538797034,
|
||||
"tag": "0019_black_mole_man",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -12,3 +12,4 @@ export * from "./notifications.js";
|
||||
export * from "./settings.js";
|
||||
export * from "./ai.js";
|
||||
export * from "./ai-chat.js";
|
||||
export * from "./org-ai-policy.js";
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
@@ -8,6 +10,7 @@ import {
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import type { MessageAttachment } from "../../types/messaging.js";
|
||||
import { organization, user } from "./auth.js";
|
||||
|
||||
// A conversation between clinic staff, scoped to a clinic (organization). `name`
|
||||
@@ -64,8 +67,31 @@ export const messages = pgTable(
|
||||
senderId: text("sender_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
body: text("body").notNull(),
|
||||
body: text("body").notNull().default(""),
|
||||
// File references / shared appointment snapshots. Null when none.
|
||||
attachments: jsonb("attachments").$type<MessageAttachment[]>(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
},
|
||||
(t) => [index("messages_conv_idx").on(t.conversationId, t.createdAt)],
|
||||
);
|
||||
|
||||
// Stored uploaded files referenced by a message attachment. Bytes are kept as
|
||||
// base64 text (simple, dependency-free; fine for the app's scale). Org-scoped.
|
||||
export const messageAttachments = pgTable(
|
||||
"message_attachments",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
uploaderId: text("uploader_id").references(() => user.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
fileName: text("file_name").notNull(),
|
||||
mimeType: text("mime_type").notNull(),
|
||||
size: integer("size").notNull(),
|
||||
data: text("data").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
},
|
||||
(t) => [index("message_attachments_org_idx").on(t.organizationId)],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||
|
||||
import { organization } from "./auth.js";
|
||||
|
||||
// Clinic-wide AI availability, controlled by owners/admins. One row per clinic
|
||||
// (organization). Absent row = AI enabled for everyone (the default).
|
||||
// aiEnabled = false → AI hidden + blocked for the whole clinic.
|
||||
// disabledForEmployees = true → AI hidden + blocked for non-admins; owners
|
||||
// and admins keep access.
|
||||
export const orgAiPolicy = pgTable("org_ai_policy", {
|
||||
organizationId: text("organization_id")
|
||||
.primaryKey()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
aiEnabled: boolean("ai_enabled").notNull().default(true),
|
||||
disabledForEmployees: boolean("disabled_for_employees")
|
||||
.notNull()
|
||||
.default(false),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import type { TaskPriority } from "../../types/task.js";
|
||||
import type { TaskPriority, TaskStatus } from "../../types/task.js";
|
||||
import { organization, user } from "./auth.js";
|
||||
|
||||
// One row per care-team to-do, scoped to a clinic (organization). Shared across
|
||||
@@ -27,6 +27,9 @@ export const tasks = pgTable(
|
||||
assigneeRole: text("assignee_role"),
|
||||
due: text("due").notNull().default("No due date"),
|
||||
priority: text("priority").$type<TaskPriority>().notNull(),
|
||||
// Board column: todo | in_progress | done. Kept in sync with `done`
|
||||
// (done === status === "done") so the legacy toggle still works.
|
||||
status: text("status").$type<TaskStatus>().notNull().default("todo"),
|
||||
patient: text("patient"),
|
||||
notes: text("notes"),
|
||||
done: boolean("done").notNull().default(false),
|
||||
|
||||
@@ -53,7 +53,9 @@ app.use((req, _res, next) => {
|
||||
// named wildcard ("*splat") rather than a bare "*".
|
||||
app.all("/api/auth/*splat", toNodeHandler(auth));
|
||||
|
||||
app.use(express.json());
|
||||
// 15mb accommodates base64-encoded message attachments (capped at 10mb of bytes
|
||||
// in the conversations route, which is ~13.3mb once base64-encoded).
|
||||
app.use(express.json({ limit: "15mb" }));
|
||||
|
||||
app.get("/health", (_req, res) => {
|
||||
res.json({ status: "ok" });
|
||||
|
||||
@@ -17,6 +17,7 @@ export const taskInputSchema = z.object({
|
||||
assigneeRole: z.enum(TASK_DEPARTMENTS).nullish(),
|
||||
due: z.string().trim().max(120).default("No due date"),
|
||||
priority: z.enum(["high", "medium", "low"]).default("medium"),
|
||||
status: z.enum(["todo", "in_progress", "done"]).default("todo"),
|
||||
patient: z.string().trim().max(200).nullish(),
|
||||
notes: z.string().max(5000).nullish(),
|
||||
});
|
||||
|
||||
+12
-2
@@ -7,6 +7,7 @@ import { auth } from "./auth.js";
|
||||
import { env } from "./env.js";
|
||||
import * as messaging from "./services/messaging.js";
|
||||
import { createNotification } from "./services/notifications.js";
|
||||
import type { MessageAttachment } from "./types/messaging.js";
|
||||
|
||||
let io: Server | null = null;
|
||||
|
||||
@@ -79,13 +80,21 @@ export function initRealtime(httpServer: HttpServer): Server {
|
||||
socket.on(
|
||||
"message:send",
|
||||
async (
|
||||
payload: { conversationId?: string; body?: string },
|
||||
payload: {
|
||||
conversationId?: string;
|
||||
body?: string;
|
||||
attachments?: MessageAttachment[];
|
||||
},
|
||||
ack?: Ack,
|
||||
) => {
|
||||
try {
|
||||
const conversationId = String(payload?.conversationId ?? "");
|
||||
const body = String(payload?.body ?? "").trim();
|
||||
if (!(conversationId && body && orgId)) {
|
||||
const attachments = Array.isArray(payload?.attachments)
|
||||
? payload.attachments
|
||||
: undefined;
|
||||
// Allow attachment-only messages; the service re-validates.
|
||||
if (!(conversationId && orgId && (body || attachments?.length))) {
|
||||
ack?.({ ok: false });
|
||||
return;
|
||||
}
|
||||
@@ -95,6 +104,7 @@ export function initRealtime(httpServer: HttpServer): Server {
|
||||
userName,
|
||||
conversationId,
|
||||
body,
|
||||
attachments,
|
||||
);
|
||||
emitToConversation(conversationId, "message:new", message);
|
||||
|
||||
|
||||
@@ -18,10 +18,55 @@ import {
|
||||
saveAiConfig,
|
||||
toAiConfig,
|
||||
} from "../services/ai/config.js";
|
||||
import { getPolicy, savePolicy } from "../services/ai/policy.js";
|
||||
import * as patients from "../services/patients.js";
|
||||
|
||||
export const aiRouter = Router();
|
||||
|
||||
// --- Clinic-wide AI policy (admin-controlled kill-switch) -------------------
|
||||
// Any member can READ the policy (the frontend needs it to gate nav/routes);
|
||||
// only owners/admins can change it.
|
||||
aiRouter.get("/policy", requireAuth, requireOrg, async (req, res, next) => {
|
||||
try {
|
||||
res.json(await getPolicy(req.organizationId!));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
aiRouter.put("/policy", requireAuth, requireOrg, async (req, res, next) => {
|
||||
try {
|
||||
const roles = String(req.memberRole ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim());
|
||||
const isAdmin = roles.some((r) => r === "owner" || r === "admin");
|
||||
if (!isAdmin) {
|
||||
throw new HttpError(403, "Only owners and admins can change this.");
|
||||
}
|
||||
const body = req.body as {
|
||||
aiEnabled?: unknown;
|
||||
disabledForEmployees?: unknown;
|
||||
};
|
||||
const saved = await savePolicy(req.organizationId!, {
|
||||
aiEnabled: Boolean(body.aiEnabled),
|
||||
disabledForEmployees: Boolean(body.disabledForEmployees),
|
||||
});
|
||||
void recordActivity({
|
||||
orgId: req.organizationId!,
|
||||
actor: { id: req.user!.id, name: req.user!.name },
|
||||
action: saved.aiEnabled
|
||||
? saved.disabledForEmployees
|
||||
? "Restricted AI to owners and admins"
|
||||
: "Enabled the AI assistant clinic-wide"
|
||||
: "Disabled the AI assistant clinic-wide",
|
||||
entityType: "patient",
|
||||
});
|
||||
res.json(saved);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Per-user AI config (no clinic/RBAC needed, like /api/settings) ---------
|
||||
aiRouter.get("/config", requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { recordActivity } from "../services/activity.js";
|
||||
import * as aiChat from "../services/ai-chat.js";
|
||||
import { getAiSettings } from "../services/ai/config.js";
|
||||
import { aiAllowedFor, getPolicy } from "../services/ai/policy.js";
|
||||
import { resolveModel } from "../services/ai/provider.js";
|
||||
import { createChatTools } from "../services/ai/tools.js";
|
||||
import { createVeil } from "../services/ai/veil.js";
|
||||
@@ -33,6 +34,50 @@ export const chatRouter = Router();
|
||||
|
||||
chatRouter.use(requireAuth, requireOrg, requirePermission({ patient: ["read"] }));
|
||||
|
||||
// Text-like uploads (CSV/JSON/TXT/…) the model should read as parseable text
|
||||
// rather than an opaque data URL. Images/PDFs are left as file parts so
|
||||
// vision-capable providers read them directly.
|
||||
const TEXT_LIKE_MEDIA = /^(text\/|application\/(json|xml|csv|x-ndjson))/i;
|
||||
const TEXT_LIKE_EXT = /\.(csv|tsv|json|txt|md|xml|ndjson|tab)$/i;
|
||||
|
||||
function decodeDataUrl(url: string): string {
|
||||
const comma = url.indexOf(",");
|
||||
if (comma === -1) return "";
|
||||
const meta = url.slice(0, comma);
|
||||
const data = url.slice(comma + 1);
|
||||
return meta.includes("base64")
|
||||
? Buffer.from(data, "base64").toString("utf8")
|
||||
: decodeURIComponent(data);
|
||||
}
|
||||
|
||||
// Replace text-like file parts with a text part carrying the file's content
|
||||
// (capped) so the agent can parse uploads (e.g. a medications list to add to
|
||||
// inventory, or a database export to import). Display/storage are unaffected —
|
||||
// this only shapes what the model sees.
|
||||
function inlineTextFiles(messages: UIMessage[]): UIMessage[] {
|
||||
return messages.map((message) => {
|
||||
if (!Array.isArray(message.parts)) return message;
|
||||
const parts = message.parts.flatMap((part) => {
|
||||
if (
|
||||
part.type === "file" &&
|
||||
typeof part.url === "string" &&
|
||||
(TEXT_LIKE_MEDIA.test(part.mediaType ?? "") ||
|
||||
TEXT_LIKE_EXT.test(part.filename ?? ""))
|
||||
) {
|
||||
const content = decodeDataUrl(part.url).slice(0, 200_000);
|
||||
return [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `--- File: ${part.filename ?? "file"} ---\n${content}`,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [part];
|
||||
});
|
||||
return { ...message, parts } as UIMessage;
|
||||
});
|
||||
}
|
||||
|
||||
function systemPrompt(veilActive: boolean, providerLabel: string): string {
|
||||
return [
|
||||
"You are temetro, a clinical assistant that helps clinicians retrieve,",
|
||||
@@ -55,10 +100,15 @@ function systemPrompt(veilActive: boolean, providerLabel: string): string {
|
||||
" asks to add/book/create one. They show an approval card; the record is only",
|
||||
" written after the clinician clicks Add. NEVER say you added/booked/created",
|
||||
" something — say you've drafted it for their approval.",
|
||||
"- proposeInventory: when the clinician wants to ADD STOCK to the clinic's",
|
||||
" inventory — e.g. they upload a list of medications/supplies with quantities",
|
||||
" (and optionally prices) to stock. Parse it into items {name, form, strength,",
|
||||
" unit, stockQuantity, reorderThreshold, expiresAt} and call proposeInventory.",
|
||||
"- proposeInvoice: when the clinician wants to bill someone — e.g. they upload",
|
||||
" a list of purchased medications/items. Parse it into line items",
|
||||
" {description, quantity, unitPrice} (use the prices in the document) and call",
|
||||
" proposeInvoice with the patient/client name.",
|
||||
" proposeInvoice with the patient/client name. (Stocking inventory vs. billing a",
|
||||
" patient are different — pick proposeInventory for the former.)",
|
||||
"- previewImport: when the clinician wants to import/migrate an existing",
|
||||
" patient database file, or add a single patient. Parse the uploaded content",
|
||||
" into our patient shape and call previewImport.",
|
||||
@@ -97,6 +147,13 @@ chatRouter.post("/", async (req, res, next) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Honour the clinic's AI kill-switch — employees can't reach the agent even
|
||||
// by bypassing the (also-gated) UI.
|
||||
const policy = await getPolicy(req.organizationId!);
|
||||
if (!aiAllowedFor(policy, req.memberRole)) {
|
||||
throw new HttpError(403, "The AI assistant is disabled for your account.");
|
||||
}
|
||||
|
||||
const settings = await getAiSettings(req.user!.id);
|
||||
const modelId = requestedModel || settings.defaultModel;
|
||||
const resolved = resolveModel(settings, modelId);
|
||||
@@ -113,7 +170,7 @@ chatRouter.post("/", async (req, res, next) => {
|
||||
},
|
||||
};
|
||||
|
||||
const modelMessages = await convertToModelMessages(messages);
|
||||
const modelMessages = await convertToModelMessages(inlineTextFiles(messages));
|
||||
const system = systemPrompt(veil.active, resolved.providerLabel);
|
||||
|
||||
const stream = createUIMessageStream({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { HttpError } from "../lib/http-error.js";
|
||||
import { requireAuth, requireOrg } from "../middleware/auth.js";
|
||||
import { emitToConversation, emitToUser } from "../realtime.js";
|
||||
import * as service from "../services/messaging.js";
|
||||
@@ -11,13 +12,48 @@ export const conversationsRouter = Router();
|
||||
// Conversations are participant-scoped within the active clinic (no extra RBAC).
|
||||
conversationsRouter.use(requireAuth, requireOrg);
|
||||
|
||||
const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024; // 10mb
|
||||
|
||||
const createSchema = z.object({
|
||||
participantIds: z.array(z.string().min(1)).min(1),
|
||||
name: z.string().trim().max(120).nullish(),
|
||||
});
|
||||
|
||||
const appointmentSnapshotSchema = z.object({
|
||||
fileNumber: z.string().max(120).default(""),
|
||||
name: z.string().max(200),
|
||||
date: z.string().max(40),
|
||||
time: z.string().max(40),
|
||||
type: z.string().max(120).default(""),
|
||||
provider: z.string().max(200).default(""),
|
||||
status: z.string().max(40).default(""),
|
||||
});
|
||||
|
||||
const attachmentSchema = z.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("file"),
|
||||
attachmentId: z.string().uuid(),
|
||||
fileName: z.string().max(255),
|
||||
mimeType: z.string().max(120),
|
||||
size: z.number().int().nonnegative(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("appointment"),
|
||||
appointment: appointmentSnapshotSchema,
|
||||
}),
|
||||
]);
|
||||
|
||||
const messageSchema = z.object({
|
||||
body: z.string().trim().min(1, "Message can't be empty.").max(5000),
|
||||
body: z.string().trim().max(5000).default(""),
|
||||
attachments: z.array(attachmentSchema).max(10).optional(),
|
||||
});
|
||||
|
||||
const uploadSchema = z.object({
|
||||
fileName: z.string().trim().min(1).max(255),
|
||||
mimeType: z.string().trim().max(120).default("application/octet-stream"),
|
||||
size: z.number().int().nonnegative().max(MAX_ATTACHMENT_BYTES),
|
||||
// Raw base64 (no data: prefix).
|
||||
data: z.string().min(1),
|
||||
});
|
||||
|
||||
// GET /api/conversations — the caller's conversations (with last message + unread)
|
||||
@@ -42,6 +78,43 @@ conversationsRouter.get("/members", async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/conversations/attachments — upload a file, get its id back
|
||||
conversationsRouter.post("/attachments", async (req, res, next) => {
|
||||
try {
|
||||
const input = uploadSchema.parse(req.body);
|
||||
if (Buffer.byteLength(input.data, "base64") > MAX_ATTACHMENT_BYTES) {
|
||||
throw new HttpError(413, "File is too large (max 10MB).");
|
||||
}
|
||||
const meta = await service.createAttachment(
|
||||
req.organizationId!,
|
||||
req.user!.id,
|
||||
input,
|
||||
);
|
||||
res.status(201).json(meta);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/conversations/attachments/:id — download a file (clinic-scoped)
|
||||
conversationsRouter.get("/attachments/:id", async (req, res, next) => {
|
||||
try {
|
||||
const file = await service.getAttachment(
|
||||
req.organizationId!,
|
||||
req.params.id as string,
|
||||
);
|
||||
if (!file) throw new HttpError(404, "Attachment not found.");
|
||||
res.setHeader("Content-Type", file.mimeType);
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`inline; filename="${encodeURIComponent(file.fileName)}"`,
|
||||
);
|
||||
res.send(Buffer.from(file.data, "base64"));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/conversations — create (or reuse an existing DM)
|
||||
conversationsRouter.post("/", async (req, res, next) => {
|
||||
try {
|
||||
@@ -75,7 +148,7 @@ conversationsRouter.get("/:id/messages", async (req, res, next) => {
|
||||
// POST /api/conversations/:id/messages — send (REST fallback; also broadcasts)
|
||||
conversationsRouter.post("/:id/messages", async (req, res, next) => {
|
||||
try {
|
||||
const { body } = messageSchema.parse(req.body);
|
||||
const { body, attachments } = messageSchema.parse(req.body);
|
||||
const conversationId = req.params.id as string;
|
||||
const { message, recipientIds } = await service.createMessage(
|
||||
req.organizationId!,
|
||||
@@ -83,6 +156,7 @@ conversationsRouter.post("/:id/messages", async (req, res, next) => {
|
||||
req.user!.name,
|
||||
conversationId,
|
||||
body,
|
||||
attachments,
|
||||
);
|
||||
emitToConversation(conversationId, "message:new", message);
|
||||
for (const recipientId of recipientIds) {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
import { db } from "../../db/index.js";
|
||||
import { orgAiPolicy } from "../../db/schema/org-ai-policy.js";
|
||||
|
||||
export type AiPolicy = {
|
||||
aiEnabled: boolean;
|
||||
disabledForEmployees: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_POLICY: AiPolicy = {
|
||||
aiEnabled: true,
|
||||
disabledForEmployees: false,
|
||||
};
|
||||
|
||||
// Read a clinic's AI policy, falling back to the permissive default when no row
|
||||
// exists yet.
|
||||
export async function getPolicy(orgId: string): Promise<AiPolicy> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
aiEnabled: orgAiPolicy.aiEnabled,
|
||||
disabledForEmployees: orgAiPolicy.disabledForEmployees,
|
||||
})
|
||||
.from(orgAiPolicy)
|
||||
.where(eq(orgAiPolicy.organizationId, orgId))
|
||||
.limit(1);
|
||||
return row ?? DEFAULT_POLICY;
|
||||
}
|
||||
|
||||
export async function savePolicy(
|
||||
orgId: string,
|
||||
policy: AiPolicy,
|
||||
): Promise<AiPolicy> {
|
||||
await db
|
||||
.insert(orgAiPolicy)
|
||||
.values({ organizationId: orgId, ...policy })
|
||||
.onConflictDoUpdate({
|
||||
target: orgAiPolicy.organizationId,
|
||||
set: { ...policy, updatedAt: new Date() },
|
||||
});
|
||||
return policy;
|
||||
}
|
||||
|
||||
// Whether a member with `role` may use the AI under `policy`. Owners/admins keep
|
||||
// access when AI is only disabled for employees; a full disable blocks everyone.
|
||||
export function aiAllowedFor(policy: AiPolicy, role: string | undefined): boolean {
|
||||
if (!policy.aiEnabled) return false;
|
||||
if (!policy.disabledForEmployees) return true;
|
||||
const names = String(role ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim());
|
||||
return names.some((r) => r === "owner" || r === "admin");
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { db } from "../../db/index.js";
|
||||
import { organization } from "../../db/schema/auth.js";
|
||||
import { appointmentInputSchema } from "../../lib/appointment-validation.js";
|
||||
import { initialsFromName } from "../../lib/initials.js";
|
||||
import { inventoryInputSchema } from "../../lib/inventory-validation.js";
|
||||
import { invoiceInputSchema } from "../../lib/invoice-validation.js";
|
||||
import { patientInputSchema } from "../../lib/patient-validation.js";
|
||||
import { prescriptionInputSchema } from "../../lib/prescription-validation.js";
|
||||
@@ -474,6 +475,77 @@ export function createChatTools(ctx: ToolContext) {
|
||||
},
|
||||
}),
|
||||
|
||||
proposeInventory: tool({
|
||||
description:
|
||||
"Propose adding one or more items to the clinic's inventory (medications/supplies) for the clinician to approve — e.g. parse an uploaded stock or purchase list into stock items. Does NOT save; it shows an approval card the clinician confirms. Each item needs a name; form/strength/unit/stockQuantity/reorderThreshold/location/expiresAt (YYYY-MM-DD)/notes are optional. Use this for STOCKING inventory; use proposeInvoice instead when billing a patient for purchased items.",
|
||||
inputSchema: z.object({
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().describe("Item / medication name"),
|
||||
form: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Dosage form, e.g. Tablet, Capsule, Syrup"),
|
||||
strength: z.string().optional().describe("e.g. 500mg"),
|
||||
unit: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Dispensing unit, e.g. box, bottle"),
|
||||
stockQuantity: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Units currently in stock"),
|
||||
reorderThreshold: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Low-stock reorder level"),
|
||||
location: z.string().optional().describe("Storage location"),
|
||||
expiresAt: z
|
||||
.string()
|
||||
.nullish()
|
||||
.describe("Expiry date, YYYY-MM-DD"),
|
||||
notes: z.string().nullish(),
|
||||
}),
|
||||
)
|
||||
.describe("Inventory items to add (prices/quantities from the document)"),
|
||||
}),
|
||||
execute: async ({ items }) => {
|
||||
step(`Drafting ${items.length} inventory item(s)`);
|
||||
// Inventory is non-PHI — no Veil resolution needed.
|
||||
const validated: unknown[] = [];
|
||||
const issues: string[] = [];
|
||||
items.forEach((item, index) => {
|
||||
const parsed = inventoryInputSchema.safeParse(item);
|
||||
if (parsed.success) {
|
||||
validated.push(parsed.data);
|
||||
} else {
|
||||
issues.push(
|
||||
...parsed.error.issues.map(
|
||||
(i) =>
|
||||
`item ${index + 1} ${i.path.join(".") || "(root)"}: ${i.message}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
writer.write({
|
||||
type: "data-actionPreview",
|
||||
data: {
|
||||
token: `inventory-${stepSeq}`,
|
||||
kind: "inventory" as const,
|
||||
record: { items: validated.length ? validated : items },
|
||||
issues,
|
||||
},
|
||||
});
|
||||
return {
|
||||
ok: issues.length === 0,
|
||||
count: validated.length,
|
||||
issues,
|
||||
note: "Preview only — awaiting clinician approval before any write.",
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
proposeInvoice: tool({
|
||||
description:
|
||||
"Propose a new invoice for the clinician to approve — e.g. parse an uploaded list of purchased medications into billable line items. Does NOT save; it shows an approval card the clinician confirms. Provide the patient/client name (a file number if known) and line items {description, quantity, unitPrice}; prices come from the uploaded document.",
|
||||
|
||||
@@ -5,12 +5,14 @@ import { member, user } from "../db/schema/auth.js";
|
||||
import {
|
||||
conversationParticipants,
|
||||
conversations,
|
||||
messageAttachments,
|
||||
messages,
|
||||
} from "../db/schema/messaging.js";
|
||||
import { HttpError } from "../lib/http-error.js";
|
||||
import type {
|
||||
ConversationMessage,
|
||||
ConversationSummary,
|
||||
MessageAttachment,
|
||||
Participant,
|
||||
} from "../types/messaging.js";
|
||||
|
||||
@@ -106,6 +108,7 @@ async function buildSummaries(
|
||||
senderId: messages.senderId,
|
||||
senderName: user.name,
|
||||
body: messages.body,
|
||||
attachments: messages.attachments,
|
||||
createdAt: messages.createdAt,
|
||||
})
|
||||
.from(messages)
|
||||
@@ -231,6 +234,7 @@ export async function getMessages(
|
||||
senderId: messages.senderId,
|
||||
senderName: user.name,
|
||||
body: messages.body,
|
||||
attachments: messages.attachments,
|
||||
createdAt: messages.createdAt,
|
||||
})
|
||||
.from(messages)
|
||||
@@ -351,6 +355,7 @@ export async function createMessage(
|
||||
senderName: string,
|
||||
conversationId: string,
|
||||
body: string,
|
||||
attachments?: MessageAttachment[] | null,
|
||||
): Promise<{ message: ConversationMessage; recipientIds: string[] }> {
|
||||
if (!(await conversationInOrg(orgId, conversationId))) {
|
||||
throw new HttpError(404, "Conversation not found.");
|
||||
@@ -358,10 +363,14 @@ export async function createMessage(
|
||||
if (!(await isParticipant(conversationId, userId))) {
|
||||
throw new HttpError(403, "You are not part of this conversation.");
|
||||
}
|
||||
const list = attachments && attachments.length > 0 ? attachments : null;
|
||||
if (!body.trim() && !list) {
|
||||
throw new HttpError(400, "Message can't be empty.");
|
||||
}
|
||||
const now = new Date();
|
||||
const [row] = await db
|
||||
.insert(messages)
|
||||
.values({ conversationId, senderId: userId, body })
|
||||
.values({ conversationId, senderId: userId, body, attachments: list })
|
||||
.returning();
|
||||
// Bump conversation recency and mark the sender's own read pointer.
|
||||
await Promise.all([
|
||||
@@ -388,12 +397,74 @@ export async function createMessage(
|
||||
senderId: userId,
|
||||
senderName,
|
||||
body: row!.body,
|
||||
attachments: row!.attachments,
|
||||
createdAt: row!.createdAt.toISOString(),
|
||||
},
|
||||
recipientIds: ids.filter((id) => id !== userId),
|
||||
};
|
||||
}
|
||||
|
||||
// --- File attachments ------------------------------------------------------
|
||||
|
||||
// Store an uploaded file (base64) and return its metadata. The bytes live in the
|
||||
// message_attachments table; messages reference them by id.
|
||||
export async function createAttachment(
|
||||
orgId: string,
|
||||
uploaderId: string,
|
||||
input: { fileName: string; mimeType: string; size: number; data: string },
|
||||
): Promise<{
|
||||
attachmentId: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
}> {
|
||||
const [row] = await db
|
||||
.insert(messageAttachments)
|
||||
.values({
|
||||
organizationId: orgId,
|
||||
uploaderId,
|
||||
fileName: input.fileName,
|
||||
mimeType: input.mimeType,
|
||||
size: input.size,
|
||||
data: input.data,
|
||||
})
|
||||
.returning({
|
||||
id: messageAttachments.id,
|
||||
fileName: messageAttachments.fileName,
|
||||
mimeType: messageAttachments.mimeType,
|
||||
size: messageAttachments.size,
|
||||
});
|
||||
return {
|
||||
attachmentId: row!.id,
|
||||
fileName: row!.fileName,
|
||||
mimeType: row!.mimeType,
|
||||
size: row!.size,
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch an attachment's bytes for download. Scoped to the caller's clinic; ids
|
||||
// are unguessable uuids, so org membership is a sufficient gate.
|
||||
export async function getAttachment(
|
||||
orgId: string,
|
||||
attachmentId: string,
|
||||
): Promise<{ fileName: string; mimeType: string; data: string } | null> {
|
||||
if (!UUID_RE.test(attachmentId)) return null;
|
||||
const [row] = await db
|
||||
.select({
|
||||
fileName: messageAttachments.fileName,
|
||||
mimeType: messageAttachments.mimeType,
|
||||
data: messageAttachments.data,
|
||||
})
|
||||
.from(messageAttachments)
|
||||
.where(
|
||||
and(
|
||||
eq(messageAttachments.id, attachmentId),
|
||||
eq(messageAttachments.organizationId, orgId),
|
||||
),
|
||||
);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function markRead(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
|
||||
@@ -26,6 +26,7 @@ function toTask(row: TaskRow): Task {
|
||||
assigneeRole: row.assigneeRole,
|
||||
due: row.due,
|
||||
priority: row.priority,
|
||||
status: row.status,
|
||||
patient: row.patient,
|
||||
notes: row.notes,
|
||||
done: row.done,
|
||||
@@ -74,6 +75,9 @@ export async function createTask(
|
||||
assigneeRole: input.assigneeRole ?? null,
|
||||
due: input.due,
|
||||
priority: input.priority,
|
||||
status: input.status,
|
||||
// Keep the legacy `done` flag in lock-step with the board column.
|
||||
done: input.status === "done",
|
||||
patient: input.patient ?? null,
|
||||
notes: input.notes ?? null,
|
||||
createdBy: creator.id,
|
||||
@@ -100,7 +104,15 @@ export async function updateTask(
|
||||
if (patch.priority !== undefined) set.priority = patch.priority;
|
||||
if (patch.patient !== undefined) set.patient = patch.patient ?? null;
|
||||
if (patch.notes !== undefined) set.notes = patch.notes ?? null;
|
||||
if (patch.done !== undefined) set.done = patch.done;
|
||||
// Keep `status` and the legacy `done` flag in sync: a status patch wins and
|
||||
// sets done; a bare done toggle maps to done/todo.
|
||||
if (patch.status !== undefined) {
|
||||
set.status = patch.status;
|
||||
set.done = patch.status === "done";
|
||||
} else if (patch.done !== undefined) {
|
||||
set.done = patch.done;
|
||||
set.status = patch.done ? "done" : "todo";
|
||||
}
|
||||
|
||||
if (Object.keys(set).length === 0) {
|
||||
const [row] = await db
|
||||
|
||||
@@ -5,12 +5,35 @@ export type Participant = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
// A shared appointment is stored as a snapshot so the card renders without an
|
||||
// extra fetch and survives the appointment later being changed/deleted.
|
||||
export type AppointmentSnapshot = {
|
||||
fileNumber: string;
|
||||
name: string;
|
||||
date: string;
|
||||
time: string;
|
||||
type: string;
|
||||
provider: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type MessageAttachment =
|
||||
| {
|
||||
kind: "file";
|
||||
attachmentId: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
}
|
||||
| { kind: "appointment"; appointment: AppointmentSnapshot };
|
||||
|
||||
export type ConversationMessage = {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
body: string;
|
||||
attachments?: MessageAttachment[] | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// `lib/tasks.ts` Task type. Scoped to the active clinic (a shared care-team
|
||||
// to-do board). `patient` is an optional free-text reference for context.
|
||||
export type TaskPriority = "high" | "medium" | "low";
|
||||
// Board column the task sits in. `done` mirrors `status === "done"`.
|
||||
export type TaskStatus = "todo" | "in_progress" | "done";
|
||||
|
||||
export type Task = {
|
||||
id: string;
|
||||
@@ -11,6 +13,7 @@ export type Task = {
|
||||
assigneeRole: string | null;
|
||||
due: string;
|
||||
priority: TaskPriority;
|
||||
status: TaskStatus;
|
||||
patient: string | null;
|
||||
notes: string | null;
|
||||
done: boolean;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { type ReactNode, useEffect, useRef } from "react";
|
||||
|
||||
import { useAiAccess } from "@/lib/ai-policy";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { canAccessRoute, defaultLandingFor, useActiveRole } from "@/lib/roles";
|
||||
|
||||
@@ -14,6 +15,7 @@ export function AppAuthGuard({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const role = useActiveRole();
|
||||
const { allowed: aiAllowed, loading: aiLoading } = useAiAccess();
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
const { data: orgs, isPending: orgsPending } =
|
||||
authClient.useListOrganizations();
|
||||
@@ -52,8 +54,14 @@ export function AppAuthGuard({ children }: { children: ReactNode }) {
|
||||
if (!ready || role == null) return;
|
||||
if (!canAccessRoute(pathname, role)) {
|
||||
router.replace(defaultLandingFor(role));
|
||||
return;
|
||||
}
|
||||
}, [ready, role, pathname, router]);
|
||||
// AI kill-switch: the chat home ("/") is off for this user — send them to
|
||||
// patients (clinical roles always have it; non-clinical never land on "/").
|
||||
if (!aiLoading && !aiAllowed && pathname === "/") {
|
||||
router.replace("/patients");
|
||||
}
|
||||
}, [ready, role, pathname, router, aiAllowed, aiLoading]);
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
Boxes,
|
||||
CalendarPlus,
|
||||
Check,
|
||||
ClipboardList,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
type InvoiceInput,
|
||||
type InvoiceLineItem,
|
||||
} from "@/lib/invoices";
|
||||
import { type InventoryInput, createInventory } from "@/lib/inventory";
|
||||
import { type PrescriptionInput, createPrescription } from "@/lib/prescriptions";
|
||||
import { type TaskInput, createTask } from "@/lib/tasks";
|
||||
import { notify } from "@/lib/toast";
|
||||
@@ -33,6 +35,7 @@ export const ACTION_ICONS = {
|
||||
task: ClipboardList,
|
||||
prescription: Pill,
|
||||
invoice: Receipt,
|
||||
inventory: Boxes,
|
||||
} as const;
|
||||
|
||||
const ICONS = ACTION_ICONS;
|
||||
@@ -61,6 +64,18 @@ export function summarize(data: ActionPreviewData): string[] {
|
||||
`${items.length} item${items.length === 1 ? "" : "s"} · ${formatMoney(total)}`,
|
||||
].filter(Boolean);
|
||||
}
|
||||
if (data.kind === "inventory") {
|
||||
const items = (r.items as InventoryInput[] | undefined) ?? [];
|
||||
return [
|
||||
`${items.length} item${items.length === 1 ? "" : "s"}`,
|
||||
items
|
||||
.map((it) =>
|
||||
[it.name, it.strength].filter(Boolean).join(" ") +
|
||||
(it.stockQuantity ? ` ×${it.stockQuantity}` : ""),
|
||||
)
|
||||
.join(", "),
|
||||
].filter(Boolean);
|
||||
}
|
||||
// prescription
|
||||
return [
|
||||
[r.medication, r.dose].filter(Boolean).join(" "),
|
||||
@@ -81,6 +96,12 @@ export async function commitAction(data: ActionPreviewData): Promise<void> {
|
||||
await createTask(data.record as TaskInput);
|
||||
} else if (data.kind === "invoice") {
|
||||
await createInvoice({ ...(data.record as InvoiceInput), source: "ai" });
|
||||
} else if (data.kind === "inventory") {
|
||||
const { items = [] } = data.record as { items?: InventoryInput[] };
|
||||
// Commit each proposed stock item via the RBAC-gated create endpoint.
|
||||
for (const item of items) {
|
||||
await createInventory(item);
|
||||
}
|
||||
} else {
|
||||
await createPrescription({
|
||||
...(data.record as PrescriptionInput),
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { Effort } from "@/lib/ai-models";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ChatInputProps = {
|
||||
onSubmit: (text: string) => void;
|
||||
onSubmit: (text: string, files: File[]) => void;
|
||||
status: ChatStatus;
|
||||
onStop?: () => void;
|
||||
model: string;
|
||||
@@ -55,36 +55,16 @@ export function ChatInput({
|
||||
const canSend =
|
||||
(value.trim().length > 0 || files.length > 0) && !isGenerating;
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
const submit = useCallback(() => {
|
||||
const trimmed = value.trim();
|
||||
// Allow submitting while generating — the panel queues it (Claude-style).
|
||||
if (!trimmed && files.length === 0) {
|
||||
return;
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (trimmed) {
|
||||
parts.push(trimmed);
|
||||
}
|
||||
// Include the text of attached files so the agent can parse them (e.g. a
|
||||
// database export to import). Read text-like files; cap each so a huge file
|
||||
// can't blow the context. Binary files are referenced by name only.
|
||||
for (const file of files) {
|
||||
const textLike =
|
||||
/\.(csv|tsv|json|txt|md|xml|ndjson|tab)$/i.test(file.name) ||
|
||||
file.type.startsWith("text/") ||
|
||||
file.type === "application/json";
|
||||
if (textLike) {
|
||||
try {
|
||||
const content = (await file.text()).slice(0, 200_000);
|
||||
parts.push(`--- File: ${file.name} ---\n${content}`);
|
||||
} catch {
|
||||
parts.push(`[Attached: ${file.name}]`);
|
||||
}
|
||||
} else {
|
||||
parts.push(`[Attached: ${file.name}]`);
|
||||
}
|
||||
}
|
||||
onSubmit(parts.join("\n\n"));
|
||||
// Hand the raw files to the panel; it sends them as proper attachment parts
|
||||
// (rendered as chips, not raw inlined text) and the backend extracts any
|
||||
// text-like content for the model.
|
||||
onSubmit(trimmed, files);
|
||||
setValue("");
|
||||
setFiles([]);
|
||||
}, [value, files, onSubmit]);
|
||||
@@ -234,7 +214,7 @@ export function ChatInput({
|
||||
<PatientFormDialog
|
||||
key={addKey}
|
||||
mode="create"
|
||||
onCreated={(fileNumber) => onSubmit(`/patient ${fileNumber}`)}
|
||||
onCreated={(fileNumber) => onSubmit(`/patient ${fileNumber}`, [])}
|
||||
onOpenChange={setAddOpen}
|
||||
open={addOpen}
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport, type ToolUIPart } from "ai";
|
||||
import {
|
||||
DefaultChatTransport,
|
||||
type FileUIPart,
|
||||
type ToolUIPart,
|
||||
} from "ai";
|
||||
import { AlertTriangle, Brain, ChevronDown, ShieldCheck, X } from "lucide-react";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentInfo,
|
||||
AttachmentPreview,
|
||||
Attachments,
|
||||
} from "@/components/ai-elements/attachments";
|
||||
import {
|
||||
ChainOfThought,
|
||||
ChainOfThoughtContent,
|
||||
@@ -83,6 +93,23 @@ import { getPatient } from "@/lib/patients";
|
||||
// pulls records instantly without the LLM (also works offline).
|
||||
const PATIENT_COMMAND = /^\/(?:patient\s+)?(\d+)$/i;
|
||||
|
||||
// Read a File into a FileUIPart (data URL). The backend extracts text-like
|
||||
// content for the model; images/PDFs are read directly by vision providers.
|
||||
function fileToPart(file: File): Promise<FileUIPart> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () =>
|
||||
resolve({
|
||||
type: "file",
|
||||
mediaType: file.type || "application/octet-stream",
|
||||
filename: file.name,
|
||||
url: reader.result as string,
|
||||
});
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function ChatPanel() {
|
||||
const { t } = useTranslation();
|
||||
const [model, setModel] = useState<string>(DEFAULT_MODEL_ID);
|
||||
@@ -92,11 +119,14 @@ export function ChatPanel() {
|
||||
// per session before the first such send — inline (no modal). `pendingConsent`
|
||||
// holds the message text waiting on that one-time approval.
|
||||
const [consented, setConsented] = useState(false);
|
||||
const [pendingConsent, setPendingConsent] = useState<string | null>(null);
|
||||
const [pendingConsent, setPendingConsent] = useState<{
|
||||
text: string;
|
||||
files: File[];
|
||||
} | null>(null);
|
||||
|
||||
// Claude-style message queue: messages submitted while the assistant is busy
|
||||
// (or waiting on the Veil gate) wait here and auto-send when it goes idle.
|
||||
const [queued, setQueued] = useState<string[]>([]);
|
||||
const [queued, setQueued] = useState<{ text: string; files: File[] }[]>([]);
|
||||
|
||||
// Persisted conversation: a client-owned thread id (a fresh one per new chat),
|
||||
// saved to the server after each exchange so history survives reloads.
|
||||
@@ -151,9 +181,10 @@ export function ChatPanel() {
|
||||
|
||||
// Run the LLM agent for a message (after any Veil gate) on a given model.
|
||||
const runAgentWith = useCallback(
|
||||
(text: string, modelId: string) => {
|
||||
async (text: string, modelId: string, files: File[] = []) => {
|
||||
const fileParts = await Promise.all(files.map(fileToPart));
|
||||
sendMessage(
|
||||
{ text },
|
||||
{ text, files: fileParts },
|
||||
{ body: { model: modelId, effort, threadId: threadIdRef.current } },
|
||||
);
|
||||
},
|
||||
@@ -161,13 +192,13 @@ export function ChatPanel() {
|
||||
);
|
||||
|
||||
const send = useCallback(
|
||||
async (text: string) => {
|
||||
async (text: string, files: File[] = []) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
if (!trimmed && files.length === 0) return;
|
||||
|
||||
// Busy or awaiting the Veil gate → queue and auto-send when idle.
|
||||
if (status === "submitted" || status === "streaming" || pendingConsent) {
|
||||
setQueued((q) => [...q, trimmed]);
|
||||
setQueued((q) => [...q, { text: trimmed, files }]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -206,10 +237,10 @@ export function ChatPanel() {
|
||||
|
||||
// Cloud model → inline Veil consent once before sending externally.
|
||||
if (isCloudModel && !consented) {
|
||||
setPendingConsent(trimmed);
|
||||
setPendingConsent({ text: trimmed, files });
|
||||
return;
|
||||
}
|
||||
runAgentWith(trimmed, model);
|
||||
void runAgentWith(trimmed, model, files);
|
||||
},
|
||||
[
|
||||
consented,
|
||||
@@ -228,22 +259,22 @@ export function ChatPanel() {
|
||||
if (status !== "ready" || pendingConsent || queued.length === 0) return;
|
||||
const [next, ...rest] = queued;
|
||||
setQueued(rest);
|
||||
if (next) void send(next);
|
||||
if (next) void send(next.text, next.files);
|
||||
}, [status, pendingConsent, queued, send]);
|
||||
|
||||
// Veil gate actions.
|
||||
const confirmConsent = useCallback(() => {
|
||||
setConsented(true);
|
||||
const text = pendingConsent;
|
||||
const pending = pendingConsent;
|
||||
setPendingConsent(null);
|
||||
if (text) runAgentWith(text, model);
|
||||
if (pending) void runAgentWith(pending.text, model, pending.files);
|
||||
}, [pendingConsent, runAgentWith, model]);
|
||||
|
||||
const useLocalInstead = useCallback(() => {
|
||||
setModel("ollama");
|
||||
const text = pendingConsent;
|
||||
const pending = pendingConsent;
|
||||
setPendingConsent(null);
|
||||
if (text) runAgentWith(text, "ollama");
|
||||
if (pending) void runAgentWith(pending.text, "ollama", pending.files);
|
||||
}, [pendingConsent, runAgentWith]);
|
||||
|
||||
const cancelConsent = useCallback(() => setPendingConsent(null), []);
|
||||
@@ -381,10 +412,13 @@ export function ChatPanel() {
|
||||
</span>
|
||||
<QueueList>
|
||||
{queued.map((q, i) => (
|
||||
<QueueItem key={`${i}-${q}`}>
|
||||
<QueueItem key={`${i}-${q.text}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<QueueItemIndicator />
|
||||
<QueueItemContent>{q}</QueueItemContent>
|
||||
<QueueItemContent>
|
||||
{q.text ||
|
||||
t("chat.queue.attachmentsOnly", { count: q.files.length })}
|
||||
</QueueItemContent>
|
||||
<QueueItemActions>
|
||||
<QueueItemAction
|
||||
aria-label={t("chat.queue.remove")}
|
||||
@@ -415,13 +449,16 @@ export function ChatPanel() {
|
||||
const firstActionPreviewIdx = message.parts.findIndex(
|
||||
(p) => p.type === "data-actionPreview",
|
||||
);
|
||||
// Attachments the clinician uploaded — rendered once as a chip group.
|
||||
const fileParts = message.parts.filter((p) => p.type === "file");
|
||||
const firstFileIdx = message.parts.findIndex((p) => p.type === "file");
|
||||
return (
|
||||
<Message from={message.role} key={message.id}>
|
||||
<MessageContent className="w-full">
|
||||
{steps.length > 0 ? (
|
||||
<ChainOfThought
|
||||
className="mb-1"
|
||||
defaultOpen={isLast && isWorking}
|
||||
defaultOpen={false}
|
||||
key={`${message.id}-cot`}
|
||||
>
|
||||
<ChainOfThoughtHeader>{t("chat.steps")}</ChainOfThoughtHeader>
|
||||
@@ -478,6 +515,26 @@ export function ChatPanel() {
|
||||
<MessageResponse key={key}>{part.text}</MessageResponse>
|
||||
);
|
||||
}
|
||||
if (part.type === "file") {
|
||||
// Render the whole message's files as one chip group, once.
|
||||
if (i !== firstFileIdx) return null;
|
||||
return (
|
||||
<Attachments className="w-full" key={key} variant="inline">
|
||||
{fileParts.map((fp, fi) => (
|
||||
<Attachment
|
||||
data={{
|
||||
...(fp as FileUIPart),
|
||||
id: `${message.id}-file-${fi}`,
|
||||
}}
|
||||
key={`${message.id}-file-${fi}`}
|
||||
>
|
||||
<AttachmentPreview />
|
||||
<AttachmentInfo />
|
||||
</Attachment>
|
||||
))}
|
||||
</Attachments>
|
||||
);
|
||||
}
|
||||
if (part.type === "data-patientCard") {
|
||||
return (
|
||||
<PatientResult
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
CommandPanel,
|
||||
} from "@/components/ui/command";
|
||||
import { Kbd, KbdGroup } from "@/components/ui/kbd";
|
||||
import { useAiAccess } from "@/lib/ai-policy";
|
||||
import { useActiveRole, visibleNavItems } from "@/lib/roles";
|
||||
|
||||
type CommandPaletteContextValue = { open: () => void };
|
||||
@@ -51,6 +52,7 @@ export function CommandPaletteProvider({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const role = useActiveRole();
|
||||
const { allowed: aiAllowed } = useAiAccess();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -71,8 +73,11 @@ export function CommandPaletteProvider({ children }: { children: ReactNode }) {
|
||||
value: "pages",
|
||||
label: t("nav.commandGroup"),
|
||||
// Flatten sub-pages so e.g. "Appointments & Schedule" is reachable.
|
||||
// Filtered by role so reception can't jump to clinical pages.
|
||||
items: visibleNavItems(role).flatMap((item) =>
|
||||
// Filtered by role so reception can't jump to clinical pages, and by
|
||||
// the AI kill-switch so the disabled chat isn't listed.
|
||||
items: visibleNavItems(role)
|
||||
.filter((item) => aiAllowed || item.id !== "new-chat")
|
||||
.flatMap((item) =>
|
||||
item.subs?.length
|
||||
? item.subs.map((sub) => ({
|
||||
id: sub.id,
|
||||
@@ -91,7 +96,7 @@ export function CommandPaletteProvider({ children }: { children: ReactNode }) {
|
||||
),
|
||||
},
|
||||
],
|
||||
[t, role],
|
||||
[t, role, aiAllowed],
|
||||
);
|
||||
|
||||
type Group = (typeof groups)[number];
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { Mail, Plus, Search, SendHorizonal } from "lucide-react";
|
||||
import {
|
||||
CalendarClock,
|
||||
Download,
|
||||
FileText,
|
||||
Mail,
|
||||
Plus,
|
||||
Search,
|
||||
SendHorizonal,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type FormEvent,
|
||||
Fragment,
|
||||
useEffect,
|
||||
@@ -30,20 +40,27 @@ import {
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "@/components/ui/menu";
|
||||
import { type Appointment, listAppointments } from "@/lib/appointments";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import {
|
||||
type ConversationMessage,
|
||||
type ConversationSummary,
|
||||
type MessageAttachment,
|
||||
type Participant,
|
||||
createConversation,
|
||||
downloadAttachment,
|
||||
getMessages,
|
||||
listClinicMembers,
|
||||
listConversations,
|
||||
uploadAttachment,
|
||||
} from "@/lib/messages";
|
||||
import { getSocket } from "@/lib/socket";
|
||||
import { notify } from "@/lib/toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
// Up to two-letter initials from a display name.
|
||||
function initials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
@@ -69,6 +86,47 @@ function sameDay(a: string, b: string): boolean {
|
||||
// one sender label, one timestamp, tighter spacing.
|
||||
const GROUP_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
// One sent attachment rendered in the thread: a downloadable file chip or a
|
||||
// shared-appointment card. Alignment (left/right) comes from the parent column.
|
||||
function SentAttachment({ att }: { att: MessageAttachment }) {
|
||||
const { t } = useTranslation();
|
||||
if (att.kind === "file") {
|
||||
return (
|
||||
<button
|
||||
className="flex max-w-[75%] items-center gap-2 rounded-2xl border bg-card px-3 py-2 text-left text-foreground text-sm transition-colors hover:bg-accent"
|
||||
onClick={() => {
|
||||
void downloadAttachment(att.attachmentId, att.fileName).catch(() => {
|
||||
/* ignore — surfaced by the browser */
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<FileText className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 max-w-48 flex-1 truncate">{att.fileName}</span>
|
||||
<Download className="size-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
const a = att.appointment;
|
||||
return (
|
||||
<div className="max-w-[75%] rounded-2xl border bg-card p-3 text-sm">
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
|
||||
<CalendarClock className="size-3.5" />
|
||||
{t("messages.attach.apptCardLabel")}
|
||||
</div>
|
||||
<p className="mt-1 font-medium text-foreground">{a.name}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{[a.date, a.time].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
{[a.type, a.provider].filter(Boolean).length > 0 && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{[a.type, a.provider].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessagesView() {
|
||||
const { t } = useTranslation();
|
||||
const { data: session } = authClient.useSession();
|
||||
@@ -101,6 +159,14 @@ export function MessagesView() {
|
||||
const [members, setMembers] = useState<Participant[]>([]);
|
||||
const [memberQuery, setMemberQuery] = useState("");
|
||||
|
||||
// Pending attachments staged for the next message + the attach UI.
|
||||
const [pending, setPending] = useState<MessageAttachment[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [apptOpen, setApptOpen] = useState(false);
|
||||
const [appts, setAppts] = useState<Appointment[]>([]);
|
||||
const [apptQuery, setApptQuery] = useState("");
|
||||
|
||||
// Refs so the socket handler (registered once) reads current values.
|
||||
const selectedIdRef = useRef<string | null>(null);
|
||||
const myIdRef = useRef<string>("");
|
||||
@@ -187,6 +253,7 @@ export function MessagesView() {
|
||||
setSelectedId(id);
|
||||
selectedIdRef.current = id;
|
||||
setDraft("");
|
||||
setPending([]);
|
||||
setMessages([]);
|
||||
getMessages(id)
|
||||
.then(setMessages)
|
||||
@@ -204,14 +271,79 @@ export function MessagesView() {
|
||||
const send = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const text = draft.trim();
|
||||
if (!(text && selected)) return;
|
||||
if (!((text || pending.length > 0) && selected)) return;
|
||||
getSocket().emit("message:send", {
|
||||
conversationId: selected.id,
|
||||
body: text,
|
||||
attachments: pending.length > 0 ? pending : undefined,
|
||||
});
|
||||
setDraft("");
|
||||
setPending([]);
|
||||
};
|
||||
|
||||
// Open the file picker; on select, upload each and stage it.
|
||||
const onPickFiles = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
event.target.value = "";
|
||||
if (!files?.length) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
for (const file of Array.from(files)) {
|
||||
if (file.size > MAX_ATTACHMENT_BYTES) {
|
||||
notify.error(
|
||||
t("messages.attach.tooLargeTitle"),
|
||||
t("messages.attach.tooLargeBody"),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const att = await uploadAttachment(file);
|
||||
setPending((prev) => [...prev, att]);
|
||||
}
|
||||
} catch {
|
||||
notify.error(
|
||||
t("messages.attach.uploadFailedTitle"),
|
||||
t("messages.attach.uploadFailedBody"),
|
||||
);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openApptPicker = () => {
|
||||
setApptQuery("");
|
||||
setApptOpen(true);
|
||||
listAppointments()
|
||||
.then(setAppts)
|
||||
.catch(() => setAppts([]));
|
||||
};
|
||||
|
||||
const attachAppointment = (a: Appointment) => {
|
||||
setPending((prev) => [
|
||||
...prev,
|
||||
{
|
||||
kind: "appointment",
|
||||
appointment: {
|
||||
fileNumber: a.fileNumber,
|
||||
name: a.name,
|
||||
date: a.date,
|
||||
time: a.time,
|
||||
type: a.type,
|
||||
provider: a.provider,
|
||||
status: a.status,
|
||||
},
|
||||
},
|
||||
]);
|
||||
setApptOpen(false);
|
||||
};
|
||||
|
||||
const removePending = (index: number) =>
|
||||
setPending((prev) => prev.filter((_, i) => i !== index));
|
||||
|
||||
const visibleAppts = useMemo(() => {
|
||||
const q = apptQuery.trim().toLowerCase();
|
||||
return q ? appts.filter((a) => a.name.toLowerCase().includes(q)) : appts;
|
||||
}, [appts, apptQuery]);
|
||||
|
||||
const openCompose = () => {
|
||||
setComposeOpen(true);
|
||||
setMemberQuery("");
|
||||
@@ -426,20 +558,28 @@ export function MessagesView() {
|
||||
{m.senderName}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[75%] rounded-2xl px-3 py-2 text-sm",
|
||||
out
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-foreground",
|
||||
!startsGroup &&
|
||||
(out ? "rounded-tr-md" : "rounded-tl-md"),
|
||||
!endsGroup &&
|
||||
(out ? "rounded-br-md" : "rounded-bl-md"),
|
||||
)}
|
||||
>
|
||||
{m.body}
|
||||
</div>
|
||||
{m.body && (
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[75%] rounded-2xl px-3 py-2 text-sm",
|
||||
out
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-foreground",
|
||||
!startsGroup &&
|
||||
(out ? "rounded-tr-md" : "rounded-tl-md"),
|
||||
!endsGroup &&
|
||||
(out ? "rounded-br-md" : "rounded-bl-md"),
|
||||
)}
|
||||
>
|
||||
{m.body}
|
||||
</div>
|
||||
)}
|
||||
{m.attachments?.map((att, ai) => (
|
||||
<SentAttachment
|
||||
att={att}
|
||||
key={`${m.id}-att-${ai}`}
|
||||
/>
|
||||
))}
|
||||
{endsGroup && (
|
||||
<span className="px-1 text-muted-foreground text-[11px]">
|
||||
{formatTime(m.createdAt)}
|
||||
@@ -453,26 +593,92 @@ export function MessagesView() {
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex items-center gap-2 rounded-2xl border bg-card/30 p-2"
|
||||
className="flex flex-col gap-2 rounded-2xl border bg-card/30 p-2"
|
||||
onSubmit={send}
|
||||
>
|
||||
<Input
|
||||
aria-label={t("messages.newMessage")}
|
||||
className="border-0 bg-transparent shadow-none before:hidden"
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder={t("messages.messagePlaceholder", {
|
||||
name: selected.name,
|
||||
})}
|
||||
value={draft}
|
||||
/>
|
||||
<Button
|
||||
aria-label={t("messages.send")}
|
||||
disabled={!draft.trim()}
|
||||
size="icon"
|
||||
type="submit"
|
||||
>
|
||||
<SendHorizonal className="size-4" />
|
||||
</Button>
|
||||
{pending.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 px-1 pt-1">
|
||||
{pending.map((att, i) => (
|
||||
<span
|
||||
className="flex items-center gap-1.5 rounded-lg bg-muted px-2 py-1 text-foreground text-xs"
|
||||
key={`pending-${i}`}
|
||||
>
|
||||
{att.kind === "file" ? (
|
||||
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<CalendarClock className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="max-w-40 truncate">
|
||||
{att.kind === "file"
|
||||
? att.fileName
|
||||
: att.appointment.name}
|
||||
</span>
|
||||
<button
|
||||
aria-label={t("messages.attach.remove")}
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() => removePending(i)}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
aria-label={t("messages.attach.file")}
|
||||
className="hidden"
|
||||
multiple
|
||||
onChange={onPickFiles}
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
/>
|
||||
<Menu>
|
||||
<MenuTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={t("messages.attach.menu")}
|
||||
disabled={uploading}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</MenuTrigger>
|
||||
<MenuPopup align="start" side="top">
|
||||
<MenuItem onClick={() => fileInputRef.current?.click()}>
|
||||
<FileText className="size-4 text-muted-foreground" />
|
||||
{t("messages.attach.file")}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={openApptPicker}>
|
||||
<CalendarClock className="size-4 text-muted-foreground" />
|
||||
{t("messages.attach.appointment")}
|
||||
</MenuItem>
|
||||
</MenuPopup>
|
||||
</Menu>
|
||||
<Input
|
||||
aria-label={t("messages.newMessage")}
|
||||
className="border-0 bg-transparent shadow-none before:hidden"
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder={
|
||||
uploading
|
||||
? t("messages.attach.uploading")
|
||||
: t("messages.messagePlaceholder", { name: selected.name })
|
||||
}
|
||||
value={draft}
|
||||
/>
|
||||
<Button
|
||||
aria-label={t("messages.send")}
|
||||
disabled={!draft.trim() && pending.length === 0}
|
||||
size="icon"
|
||||
type="submit"
|
||||
>
|
||||
<SendHorizonal className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
@@ -549,6 +755,60 @@ export function MessagesView() {
|
||||
</DialogPanel>
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
|
||||
{/* Share an appointment: search by patient, pick one to attach */}
|
||||
<Dialog onOpenChange={setApptOpen} open={apptOpen}>
|
||||
<DialogPopup className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("messages.attach.apptDialogTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("messages.attach.apptDialogDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogPanel className="flex flex-col gap-2">
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label={t("messages.attach.apptSearchPlaceholder")}
|
||||
className="pl-9"
|
||||
onChange={(e) => setApptQuery(e.target.value)}
|
||||
placeholder={t("messages.attach.apptSearchPlaceholder")}
|
||||
size="sm"
|
||||
value={apptQuery}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex max-h-80 flex-col gap-1 overflow-y-auto">
|
||||
{appts.length === 0 ? (
|
||||
<p className="px-1 py-4 text-center text-muted-foreground text-sm">
|
||||
{t("messages.attach.apptEmpty")}
|
||||
</p>
|
||||
) : visibleAppts.length === 0 ? (
|
||||
<p className="px-1 py-4 text-center text-muted-foreground text-sm">
|
||||
{t("messages.attach.apptNoMatches")}
|
||||
</p>
|
||||
) : (
|
||||
visibleAppts.map((a) => (
|
||||
<button
|
||||
className="flex w-full flex-col gap-0.5 rounded-lg px-2 py-2 text-left transition-colors hover:bg-accent"
|
||||
key={a.id}
|
||||
onClick={() => attachAppointment(a)}
|
||||
type="button"
|
||||
>
|
||||
<span className="font-medium text-foreground text-sm">
|
||||
{a.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{[a.date, a.time, a.type, a.provider]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,13 @@ import {
|
||||
FieldLabel,
|
||||
SettingsCard,
|
||||
SettingsSection,
|
||||
ToggleRow,
|
||||
} from "@/components/settings/settings-parts";
|
||||
import {
|
||||
type AiPolicy,
|
||||
getAiPolicy,
|
||||
saveAiPolicy,
|
||||
} from "@/lib/ai-policy";
|
||||
import { AI_MODELS, EFFORT_LEVELS, type Effort } from "@/lib/ai-models";
|
||||
import {
|
||||
type AiConfig,
|
||||
@@ -28,6 +34,7 @@ import {
|
||||
saveAiConfig,
|
||||
testAiConnection,
|
||||
} from "@/lib/ai-settings";
|
||||
import { useActiveRole } from "@/lib/roles";
|
||||
import { notify } from "@/lib/toast";
|
||||
|
||||
const PROVIDERS: ApiProvider[] = ["openai", "anthropic", "gemini"];
|
||||
@@ -52,6 +59,55 @@ export function AIPanel() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
// Clinic-wide AI availability (admin-controlled kill-switch).
|
||||
const role = useActiveRole();
|
||||
const isAdmin = role === "owner" || role === "admin";
|
||||
const [policy, setPolicy] = useState<AiPolicy | null>(null);
|
||||
const [policyBaseline, setPolicyBaseline] = useState<AiPolicy | null>(null);
|
||||
const [savingPolicy, setSavingPolicy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getAiPolicy()
|
||||
.then((p) => {
|
||||
if (cancelled) return;
|
||||
setPolicy(p);
|
||||
setPolicyBaseline(p);
|
||||
})
|
||||
.catch(() => {
|
||||
/* leave null; section just won't render its controls */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const policyDirty =
|
||||
policy != null &&
|
||||
policyBaseline != null &&
|
||||
JSON.stringify(policy) !== JSON.stringify(policyBaseline);
|
||||
|
||||
const savePolicy = async () => {
|
||||
if (!policy) return;
|
||||
setSavingPolicy(true);
|
||||
try {
|
||||
const saved = await saveAiPolicy(policy);
|
||||
setPolicy(saved);
|
||||
setPolicyBaseline(saved);
|
||||
notify.success(
|
||||
t("settings.ai.availability.savedTitle"),
|
||||
t("settings.ai.availability.savedBody"),
|
||||
);
|
||||
} catch {
|
||||
notify.error(
|
||||
t("settings.ai.saveFailedTitle"),
|
||||
t("settings.ai.saveFailedBody"),
|
||||
);
|
||||
} finally {
|
||||
setSavingPolicy(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getAiConfig()
|
||||
@@ -142,6 +198,63 @@ export function AIPanel() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{policy ? (
|
||||
<SettingsSection
|
||||
description={t("settings.ai.availability.description")}
|
||||
title={t("settings.ai.availability.title")}
|
||||
>
|
||||
{isAdmin ? (
|
||||
<div className="space-y-3">
|
||||
<ToggleRow
|
||||
checked={policy.aiEnabled}
|
||||
description={t("settings.ai.availability.enabledHint")}
|
||||
onCheckedChange={(checked) =>
|
||||
setPolicy((p) => (p ? { ...p, aiEnabled: checked } : p))
|
||||
}
|
||||
title={t("settings.ai.availability.enabled")}
|
||||
/>
|
||||
{policy.aiEnabled ? (
|
||||
<ToggleRow
|
||||
checked={policy.disabledForEmployees}
|
||||
description={t(
|
||||
"settings.ai.availability.employeesOnlyHint",
|
||||
)}
|
||||
onCheckedChange={(checked) =>
|
||||
setPolicy((p) =>
|
||||
p ? { ...p, disabledForEmployees: checked } : p,
|
||||
)
|
||||
}
|
||||
title={t("settings.ai.availability.employeesOnly")}
|
||||
/>
|
||||
) : null}
|
||||
{policyDirty ? (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
disabled={savingPolicy}
|
||||
onClick={savePolicy}
|
||||
size="sm"
|
||||
>
|
||||
{savingPolicy
|
||||
? t("settings.ai.saving")
|
||||
: t("settings.ai.saveChanges")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<SettingsCard className="px-4 py-3.5">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{policy.aiEnabled
|
||||
? policy.disabledForEmployees
|
||||
? t("settings.ai.availability.readonlyEmployeesOnly")
|
||||
: t("settings.ai.availability.readonlyEnabled")
|
||||
: t("settings.ai.availability.readonlyDisabled")}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
)}
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
|
||||
<SettingsSection
|
||||
description={t("settings.ai.modeDescription")}
|
||||
title={t("settings.ai.modeTitle")}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAiAccess } from "@/lib/ai-policy";
|
||||
import { useActiveRole, visibleNavItems } from "@/lib/roles";
|
||||
import { motion } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
@@ -28,10 +29,14 @@ export function DashboardSidebar() {
|
||||
const { state } = useSidebar();
|
||||
const { t } = useTranslation();
|
||||
const role = useActiveRole();
|
||||
const { allowed: aiAllowed } = useAiAccess();
|
||||
const isCollapsed = state === "collapsed";
|
||||
|
||||
// Hide clinical nav from non-clinical roles (e.g. reception). See lib/roles.ts.
|
||||
const dashboardRoutes: Route[] = visibleNavItems(role).map((item) => ({
|
||||
// Also drop the AI "New chat" entry when the clinic's AI kill-switch applies.
|
||||
const dashboardRoutes: Route[] = visibleNavItems(role)
|
||||
.filter((item) => aiAllowed || item.id !== "new-chat")
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
title: t(item.labelKey),
|
||||
icon: <item.icon className="size-4" />,
|
||||
@@ -92,7 +97,7 @@ export function DashboardSidebar() {
|
||||
</SidebarHeader>
|
||||
<SidebarContent className="gap-4 px-2 py-4">
|
||||
<DashboardNavigation routes={dashboardRoutes} />
|
||||
<NavChatHistory />
|
||||
{aiAllowed && <NavChatHistory />}
|
||||
</SidebarContent>
|
||||
<SidebarFooter className="p-2">
|
||||
<NavUser />
|
||||
|
||||
@@ -12,9 +12,12 @@ import {
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { ROLE_LABELS } from "@/lib/access";
|
||||
import type { TaskStatus } from "@/lib/tasks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Priority, Task } from "@/components/tasks/tasks-view";
|
||||
|
||||
const STATUSES: TaskStatus[] = ["todo", "in_progress", "done"];
|
||||
|
||||
const priorityVariant: Record<Priority, "destructive" | "secondary" | "outline"> =
|
||||
{
|
||||
high: "destructive",
|
||||
@@ -33,12 +36,12 @@ export function TaskDetailSheet({
|
||||
task,
|
||||
open,
|
||||
onOpenChange,
|
||||
onToggle,
|
||||
onMove,
|
||||
}: {
|
||||
task: Task | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onToggle: (id: string) => void;
|
||||
onMove: (id: string, status: TaskStatus) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -67,9 +70,7 @@ export function TaskDetailSheet({
|
||||
{t("tasks.detail.status")}
|
||||
</dt>
|
||||
<dd className="text-foreground">
|
||||
{task.done
|
||||
? t("tasks.detail.completed")
|
||||
: t("tasks.detail.open")}
|
||||
{t(`tasks.status.${task.status}`)}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("tasks.detail.assignedTo")}
|
||||
@@ -110,16 +111,23 @@ export function TaskDetailSheet({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => onToggle(task.id)}
|
||||
type="button"
|
||||
variant={task.done ? "outline" : "default"}
|
||||
>
|
||||
{task.done
|
||||
? t("tasks.detail.reopen")
|
||||
: t("tasks.detail.complete")}
|
||||
</Button>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("tasks.detail.moveTo")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{STATUSES.map((s) => (
|
||||
<Button
|
||||
key={s}
|
||||
onClick={() => onMove(task.id, s)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={task.status === s ? "default" : "outline"}
|
||||
>
|
||||
{t(`tasks.status.${s}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Plus } from "lucide-react";
|
||||
import { CalendarClock, Plus } from "lucide-react";
|
||||
import {
|
||||
type DragEvent,
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
type Priority,
|
||||
type Task,
|
||||
type TaskInput,
|
||||
type TaskStatus,
|
||||
createTask,
|
||||
listTasks,
|
||||
updateTask,
|
||||
@@ -47,7 +49,8 @@ function deptLabel(role: string): string {
|
||||
|
||||
export type { Priority, Task } from "@/lib/tasks";
|
||||
|
||||
type Filter = "all" | "open" | "done";
|
||||
// The board columns, left → right.
|
||||
const COLUMNS: TaskStatus[] = ["todo", "in_progress", "done"];
|
||||
|
||||
const priorityVariant: Record<Priority, "destructive" | "secondary" | "outline"> =
|
||||
{
|
||||
@@ -56,32 +59,12 @@ const priorityVariant: Record<Priority, "destructive" | "secondary" | "outline">
|
||||
low: "outline",
|
||||
};
|
||||
|
||||
function CheckButton({
|
||||
done,
|
||||
onClick,
|
||||
label,
|
||||
}: {
|
||||
done: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
aria-label={label}
|
||||
aria-pressed={done}
|
||||
className={cn(
|
||||
"flex size-5 shrink-0 items-center justify-center rounded-md border transition-colors",
|
||||
done
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-input hover:border-ring",
|
||||
)}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
{done && <Check className="size-3.5" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
// A subtle accent dot per column so the three are quick to tell apart.
|
||||
const columnDot: Record<TaskStatus, string> = {
|
||||
todo: "bg-muted-foreground",
|
||||
in_progress: "bg-primary",
|
||||
done: "bg-success",
|
||||
};
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
@@ -99,10 +82,12 @@ function AddTaskDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onAdd,
|
||||
initialStatus,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAdd: (task: TaskInput) => void;
|
||||
initialStatus: TaskStatus;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [title, setTitle] = useState("");
|
||||
@@ -111,6 +96,12 @@ function AddTaskDialog({
|
||||
const [department, setDepartment] = useState<string>("reception");
|
||||
const [due, setDue] = useState("");
|
||||
const [priority, setPriority] = useState<Priority>("medium");
|
||||
const [status, setStatus] = useState<TaskStatus>(initialStatus);
|
||||
|
||||
// Re-seed the column when the dialog is opened from a specific column.
|
||||
useEffect(() => {
|
||||
if (open) setStatus(initialStatus);
|
||||
}, [open, initialStatus]);
|
||||
|
||||
const reset = () => {
|
||||
setTitle("");
|
||||
@@ -119,6 +110,7 @@ function AddTaskDialog({
|
||||
setDepartment("reception");
|
||||
setDue("");
|
||||
setPriority("medium");
|
||||
setStatus(initialStatus);
|
||||
};
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
@@ -137,6 +129,7 @@ function AddTaskDialog({
|
||||
assigneeRole: assigneeMode === "self" ? null : department,
|
||||
due: due.trim() || "No due date",
|
||||
priority,
|
||||
status,
|
||||
});
|
||||
notify.success(t("tasks.toast.addedTitle"), title.trim());
|
||||
reset();
|
||||
@@ -208,7 +201,7 @@ function AddTaskDialog({
|
||||
</TabsPanel>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Field label={t("tasks.dialog.due")}>
|
||||
<Input
|
||||
onChange={(e) => setDue(e.target.value)}
|
||||
@@ -227,6 +220,19 @@ function AddTaskDialog({
|
||||
<option value="low">{t("tasks.priority.low")}</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("tasks.dialog.statusLabel")}>
|
||||
<select
|
||||
className={controlClass}
|
||||
onChange={(e) => setStatus(e.target.value as TaskStatus)}
|
||||
value={status}
|
||||
>
|
||||
{COLUMNS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(`tasks.status.${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
|
||||
@@ -242,13 +248,68 @@ function AddTaskDialog({
|
||||
);
|
||||
}
|
||||
|
||||
// One draggable task card on the board.
|
||||
function TaskCard({
|
||||
task,
|
||||
onOpen,
|
||||
onDragStart,
|
||||
}: {
|
||||
task: Task;
|
||||
onOpen: () => void;
|
||||
onDragStart: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className="group flex cursor-pointer flex-col gap-2 rounded-2xl border bg-card p-3 transition-colors hover:border-ring"
|
||||
draggable
|
||||
onClick={onOpen}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData("text/plain", task.id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
onDragStart();
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
task.done
|
||||
? "text-muted-foreground line-through"
|
||||
: "font-medium text-foreground",
|
||||
)}
|
||||
>
|
||||
{task.title}
|
||||
</span>
|
||||
<span className="truncate text-muted-foreground text-xs">
|
||||
{task.assigneeRole
|
||||
? t("tasks.list.forDept", { dept: deptLabel(task.assigneeRole) })
|
||||
: t("tasks.list.personal")}
|
||||
{task.createdByName
|
||||
? ` · ${t("tasks.list.byCreator", { name: task.createdByName })}`
|
||||
: ""}
|
||||
</span>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="flex min-w-0 items-center gap-1 text-muted-foreground text-xs">
|
||||
<CalendarClock className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{task.due}</span>
|
||||
</span>
|
||||
<Badge className="shrink-0" variant={priorityVariant[task.priority]}>
|
||||
{t(`tasks.priority.${task.priority}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TasksView() {
|
||||
const { t } = useTranslation();
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [addStatus, setAddStatus] = useState<TaskStatus>("todo");
|
||||
const [dragId, setDragId] = useState<string | null>(null);
|
||||
const [dragOver, setDragOver] = useState<TaskStatus | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -266,26 +327,29 @@ export function TasksView() {
|
||||
|
||||
const selected = tasks.find((task) => task.id === selectedId) ?? null;
|
||||
|
||||
const visible = useMemo(() => {
|
||||
if (filter === "open") return tasks.filter((task) => !task.done);
|
||||
if (filter === "done") return tasks.filter((task) => task.done);
|
||||
return tasks;
|
||||
}, [tasks, filter]);
|
||||
const byStatus = useMemo(() => {
|
||||
const groups: Record<TaskStatus, Task[]> = {
|
||||
todo: [],
|
||||
in_progress: [],
|
||||
done: [],
|
||||
};
|
||||
for (const task of tasks) groups[task.status]?.push(task);
|
||||
return groups;
|
||||
}, [tasks]);
|
||||
|
||||
// Optimistically flip done, then persist; roll back on failure.
|
||||
const toggle = async (id: string) => {
|
||||
// Optimistically move a task to a new column, then persist; roll back on fail.
|
||||
const moveTask = async (id: string, status: TaskStatus) => {
|
||||
const current = tasks.find((task) => task.id === id);
|
||||
if (!current) return;
|
||||
const next = !current.done;
|
||||
if (!current || current.status === status) return;
|
||||
setTasks((prev) =>
|
||||
prev.map((row) => (row.id === id ? { ...row, done: next } : row)),
|
||||
prev.map((row) =>
|
||||
row.id === id ? { ...row, status, done: status === "done" } : row,
|
||||
),
|
||||
);
|
||||
try {
|
||||
await updateTask(id, { done: next });
|
||||
await updateTask(id, { status });
|
||||
} catch {
|
||||
setTasks((prev) =>
|
||||
prev.map((row) => (row.id === id ? { ...row, done: current.done } : row)),
|
||||
);
|
||||
setTasks((prev) => prev.map((row) => (row.id === id ? current : row)));
|
||||
notify.error(
|
||||
t("tasks.toast.updateFailedTitle"),
|
||||
t("tasks.toast.updateFailedBody"),
|
||||
@@ -310,97 +374,109 @@ export function TasksView() {
|
||||
}
|
||||
};
|
||||
|
||||
const openAdd = (status: TaskStatus) => {
|
||||
setAddStatus(status);
|
||||
setAddOpen(true);
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent, status: TaskStatus) => {
|
||||
event.preventDefault();
|
||||
setDragOver(null);
|
||||
const id = dragId ?? event.dataTransfer.getData("text/plain");
|
||||
setDragId(null);
|
||||
if (id) void moveTask(id, status);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6 px-6 py-10">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="font-semibold text-2xl tracking-tight">
|
||||
{t("tasks.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">{t("tasks.subtitle")}</p>
|
||||
</div>
|
||||
<Button
|
||||
className="rounded-3xl"
|
||||
onClick={() => setAddOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{t("tasks.new")}
|
||||
</Button>
|
||||
<div className="flex h-full w-full flex-col gap-6 px-6 py-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="font-semibold text-2xl tracking-tight">
|
||||
{t("tasks.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">{t("tasks.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center gap-1 rounded-2xl border bg-card/30 p-1 sm:w-fit">
|
||||
{(["all", "open", "done"] as Filter[]).map((f) => (
|
||||
<Button
|
||||
className="flex-1 sm:flex-none"
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={filter === f ? "secondary" : "ghost"}
|
||||
>
|
||||
{t(`tasks.filters.${f}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border overflow-hidden rounded-2xl border bg-card/30">
|
||||
{visible.length === 0 ? (
|
||||
<p className="px-4 py-10 text-center text-muted-foreground text-sm">
|
||||
{t("tasks.empty")}
|
||||
</p>
|
||||
) : (
|
||||
visible.map((task) => (
|
||||
<div className="flex items-center gap-3 px-4 py-3" key={task.id}>
|
||||
<CheckButton
|
||||
done={task.done}
|
||||
label={
|
||||
task.done ? t("tasks.markNotDone") : t("tasks.markDone")
|
||||
}
|
||||
onClick={() => toggle(task.id)}
|
||||
/>
|
||||
<button
|
||||
className="flex min-w-0 flex-1 flex-col text-left"
|
||||
onClick={() => openTask(task.id)}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"truncate text-sm",
|
||||
task.done
|
||||
? "text-muted-foreground line-through"
|
||||
: "font-medium text-foreground",
|
||||
)}
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{COLUMNS.map((status) => {
|
||||
const column = byStatus[status];
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"flex min-h-0 flex-col gap-3 rounded-2xl border bg-card/30 p-3 transition-colors",
|
||||
dragOver === status && "border-ring bg-accent/40",
|
||||
)}
|
||||
key={status}
|
||||
onDragLeave={() => setDragOver((s) => (s === status ? null : s))}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(status);
|
||||
}}
|
||||
onDrop={(e) => handleDrop(e, status)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn("size-2 rounded-full", columnDot[status])}
|
||||
/>
|
||||
<span className="font-medium text-sm">
|
||||
{t(`tasks.status.${status}`)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{column.length}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
aria-label={t("tasks.board.addTask")}
|
||||
onClick={() => openAdd(status)}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{task.title}
|
||||
</span>
|
||||
<span className="truncate text-muted-foreground text-xs">
|
||||
{task.assigneeRole
|
||||
? t("tasks.list.forDept", {
|
||||
dept: deptLabel(task.assigneeRole),
|
||||
})
|
||||
: t("tasks.list.personal")}
|
||||
{task.createdByName
|
||||
? ` · ${t("tasks.list.byCreator", { name: task.createdByName })}`
|
||||
: ""}
|
||||
</span>
|
||||
</button>
|
||||
<Badge
|
||||
className="shrink-0"
|
||||
variant={priorityVariant[task.priority]}
|
||||
>
|
||||
{t(`tasks.priority.${task.priority}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
|
||||
{column.length === 0 ? (
|
||||
<p className="px-1 py-6 text-center text-muted-foreground text-xs">
|
||||
{t("tasks.board.emptyColumn")}
|
||||
</p>
|
||||
) : (
|
||||
column.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
onDragStart={() => setDragId(task.id)}
|
||||
onOpen={() => openTask(task.id)}
|
||||
task={task}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
<Button
|
||||
className="justify-start text-muted-foreground"
|
||||
onClick={() => openAdd(status)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{t("tasks.board.addTask")}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<AddTaskDialog onAdd={addTask} onOpenChange={setAddOpen} open={addOpen} />
|
||||
<AddTaskDialog
|
||||
initialStatus={addStatus}
|
||||
onAdd={addTask}
|
||||
onOpenChange={setAddOpen}
|
||||
open={addOpen}
|
||||
/>
|
||||
|
||||
<TaskDetailSheet
|
||||
onMove={moveTask}
|
||||
onOpenChange={setSheetOpen}
|
||||
onToggle={toggle}
|
||||
open={sheetOpen}
|
||||
task={selected}
|
||||
/>
|
||||
|
||||
@@ -56,7 +56,8 @@ export type ActionPreviewKind =
|
||||
| "appointment"
|
||||
| "task"
|
||||
| "prescription"
|
||||
| "invoice";
|
||||
| "invoice"
|
||||
| "inventory";
|
||||
export type ActionPreviewData = {
|
||||
token: string;
|
||||
kind: ActionPreviewKind;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { apiFetch } from "@/lib/api-client";
|
||||
import { useActiveRole } from "@/lib/roles";
|
||||
|
||||
// Mirrors backend/src/services/ai/policy.ts. Clinic-wide AI availability, set by
|
||||
// owners/admins. Absent/default = AI enabled for everyone.
|
||||
export type AiPolicy = {
|
||||
aiEnabled: boolean;
|
||||
disabledForEmployees: boolean;
|
||||
};
|
||||
|
||||
export function getAiPolicy(): Promise<AiPolicy> {
|
||||
return apiFetch<AiPolicy>("/api/ai/policy");
|
||||
}
|
||||
|
||||
export function saveAiPolicy(policy: AiPolicy): Promise<AiPolicy> {
|
||||
return apiFetch<AiPolicy>("/api/ai/policy", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(policy),
|
||||
});
|
||||
}
|
||||
|
||||
function isAdminRole(role: string | null): boolean {
|
||||
return String(role ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.some((r) => r === "owner" || r === "admin");
|
||||
}
|
||||
|
||||
// Whether a member with `role` may use the AI under `policy`. Owners/admins keep
|
||||
// access when AI is only disabled for employees.
|
||||
export function aiAllowedFor(
|
||||
policy: AiPolicy | null,
|
||||
role: string | null,
|
||||
): boolean {
|
||||
if (!policy) return true; // optimistic while loading — avoids nav flicker
|
||||
if (!policy.aiEnabled) return false;
|
||||
if (!policy.disabledForEmployees) return true;
|
||||
return isAdminRole(role);
|
||||
}
|
||||
|
||||
// Whether the current user may use the AI (clinic policy + their role). Returns
|
||||
// `allowed: true` while loading so the AI nav doesn't flash out then back in.
|
||||
export function useAiAccess(): { allowed: boolean; loading: boolean } {
|
||||
const role = useActiveRole();
|
||||
const [policy, setPolicy] = useState<AiPolicy | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
getAiPolicy()
|
||||
.then((p) => {
|
||||
if (active) setPolicy(p);
|
||||
})
|
||||
.catch(() => {
|
||||
/* leave permissive default; backend still enforces */
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { allowed: aiAllowedFor(policy, role), loading };
|
||||
}
|
||||
@@ -542,6 +542,16 @@
|
||||
"empty": "No tasks here.",
|
||||
"markDone": "Mark as done",
|
||||
"markNotDone": "Mark as not done",
|
||||
"status": {
|
||||
"todo": "To Do",
|
||||
"in_progress": "In Progress",
|
||||
"done": "Done"
|
||||
},
|
||||
"board": {
|
||||
"addTask": "Add task",
|
||||
"moveTo": "Move to",
|
||||
"emptyColumn": "No tasks."
|
||||
},
|
||||
"filters": {
|
||||
"all": "All",
|
||||
"open": "Open",
|
||||
@@ -566,6 +576,7 @@
|
||||
"due": "Due",
|
||||
"duePlaceholder": "e.g. Today",
|
||||
"priorityLabel": "Priority",
|
||||
"statusLabel": "Status",
|
||||
"cancel": "Cancel",
|
||||
"add": "Add task"
|
||||
},
|
||||
@@ -588,7 +599,8 @@
|
||||
"patient": "Patient",
|
||||
"details": "Details",
|
||||
"reopen": "Reopen task",
|
||||
"complete": "Mark complete"
|
||||
"complete": "Mark complete",
|
||||
"moveTo": "Move to"
|
||||
},
|
||||
"toast": {
|
||||
"needSubjectTitle": "Add a subject",
|
||||
@@ -626,7 +638,25 @@
|
||||
"noMembers": "No other clinic members yet. Invite colleagues from Settings → Care team."
|
||||
},
|
||||
"startFailedTitle": "Couldn't start conversation",
|
||||
"startFailedBody": "Please try again."
|
||||
"startFailedBody": "Please try again.",
|
||||
"attach": {
|
||||
"menu": "Attach",
|
||||
"file": "Files",
|
||||
"appointment": "Appointments",
|
||||
"remove": "Remove",
|
||||
"uploading": "Uploading…",
|
||||
"uploadFailedTitle": "Couldn't attach file",
|
||||
"uploadFailedBody": "The file may be too large (max 10MB). Please try again.",
|
||||
"tooLargeTitle": "File too large",
|
||||
"tooLargeBody": "Attachments are limited to 10MB.",
|
||||
"download": "Download",
|
||||
"apptDialogTitle": "Share an appointment",
|
||||
"apptDialogDescription": "Search by patient name, then pick an appointment to attach.",
|
||||
"apptSearchPlaceholder": "Patient name…",
|
||||
"apptNoMatches": "No appointments match.",
|
||||
"apptEmpty": "No appointments yet.",
|
||||
"apptCardLabel": "Appointment"
|
||||
}
|
||||
},
|
||||
"analysis": {
|
||||
"title": "Analysis",
|
||||
@@ -795,7 +825,8 @@
|
||||
},
|
||||
"queue": {
|
||||
"label": "Queued · {{count}}",
|
||||
"remove": "Remove from queue"
|
||||
"remove": "Remove from queue",
|
||||
"attachmentsOnly": "{{count}} attachment(s)"
|
||||
},
|
||||
"veil": {
|
||||
"title": "Veil",
|
||||
@@ -810,14 +841,17 @@
|
||||
"appointment": "Proposed appointment",
|
||||
"task": "Proposed task",
|
||||
"prescription": "Proposed prescription",
|
||||
"invoice": "Proposed invoice"
|
||||
"invoice": "Proposed invoice",
|
||||
"inventory": "Proposed inventory"
|
||||
},
|
||||
"kind": {
|
||||
"appointment": "Appointment added.",
|
||||
"task": "Task added.",
|
||||
"prescription": "Prescription added.",
|
||||
"invoice": "Invoice added."
|
||||
"invoice": "Invoice added.",
|
||||
"inventory": "Inventory updated."
|
||||
},
|
||||
"inventoryItems": "{{count}} item(s)",
|
||||
"approve": "Add",
|
||||
"adding": "Adding…",
|
||||
"discard": "Discard",
|
||||
@@ -1258,6 +1292,19 @@
|
||||
"backupDesc": "Export an encrypted backup of your signing key to restore it on a new device."
|
||||
},
|
||||
"ai": {
|
||||
"availability": {
|
||||
"title": "Availability",
|
||||
"description": "Control who in your clinic can use the AI assistant. When disabled, the AI page and sidebar entry are hidden and the assistant cannot be reached.",
|
||||
"enabled": "Enable AI assistant",
|
||||
"enabledHint": "Turn the AI assistant on for your clinic. Off hides it for everyone.",
|
||||
"employeesOnly": "Disable for employees only",
|
||||
"employeesOnlyHint": "Hide the AI from staff; owners and admins keep access.",
|
||||
"savedTitle": "AI availability updated",
|
||||
"savedBody": "The change applies across your clinic.",
|
||||
"readonlyEnabled": "The AI assistant is enabled for your clinic.",
|
||||
"readonlyEmployeesOnly": "The AI assistant is restricted to owners and admins.",
|
||||
"readonlyDisabled": "The AI assistant is disabled for your clinic."
|
||||
},
|
||||
"modeTitle": "Inference mode",
|
||||
"modeDescription": "Choose how temetro runs the AI. A cloud API key sends data off your infrastructure; a local model keeps everything on your machine.",
|
||||
"mode": "Mode",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiFetch } from "@/lib/api-client";
|
||||
import { API_BASE_URL, apiFetch } from "@/lib/api-client";
|
||||
|
||||
// Messaging shapes. Mirror the backend `src/types/messaging.ts`.
|
||||
export type Participant = {
|
||||
@@ -6,12 +6,35 @@ export type Participant = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
// A shared appointment is stored as a snapshot (renders without a refetch and
|
||||
// survives the appointment changing/being deleted).
|
||||
export type AppointmentSnapshot = {
|
||||
fileNumber: string;
|
||||
name: string;
|
||||
date: string;
|
||||
time: string;
|
||||
type: string;
|
||||
provider: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type MessageAttachment =
|
||||
| {
|
||||
kind: "file";
|
||||
attachmentId: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
}
|
||||
| { kind: "appointment"; appointment: AppointmentSnapshot };
|
||||
|
||||
export type ConversationMessage = {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
body: string;
|
||||
attachments?: MessageAttachment[] | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
@@ -57,13 +80,72 @@ export function getMessages(
|
||||
export function sendMessageRest(
|
||||
conversationId: string,
|
||||
body: string,
|
||||
attachments?: MessageAttachment[],
|
||||
): Promise<ConversationMessage> {
|
||||
return apiFetch<ConversationMessage>(
|
||||
`/api/conversations/${conversationId}/messages`,
|
||||
{ method: "POST", body: JSON.stringify({ body }) },
|
||||
{ method: "POST", body: JSON.stringify({ body, attachments }) },
|
||||
);
|
||||
}
|
||||
|
||||
// Upload a file and get back a file attachment ready to send with a message.
|
||||
export async function uploadAttachment(
|
||||
file: File,
|
||||
): Promise<Extract<MessageAttachment, { kind: "file" }>> {
|
||||
const data = await fileToBase64(file);
|
||||
const meta = await apiFetch<{
|
||||
attachmentId: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
}>("/api/conversations/attachments", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
fileName: file.name,
|
||||
mimeType: file.type || "application/octet-stream",
|
||||
size: file.size,
|
||||
data,
|
||||
}),
|
||||
});
|
||||
return { kind: "file", ...meta };
|
||||
}
|
||||
|
||||
// Fetch an attachment with credentials and trigger a browser download. (A plain
|
||||
// link wouldn't carry the auth cookie cross-origin.)
|
||||
export async function downloadAttachment(
|
||||
attachmentId: string,
|
||||
fileName: string,
|
||||
): Promise<void> {
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/conversations/attachments/${attachmentId}`,
|
||||
{ credentials: "include" },
|
||||
);
|
||||
if (!res.ok) throw new Error("Download failed");
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// Read a File as raw base64 (without the `data:...;base64,` prefix).
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = String(reader.result);
|
||||
const comma = result.indexOf(",");
|
||||
resolve(comma === -1 ? result : result.slice(comma + 1));
|
||||
};
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function markConversationRead(conversationId: string): Promise<void> {
|
||||
return apiFetch<void>(`/api/conversations/${conversationId}/read`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -3,6 +3,8 @@ import { apiFetch } from "@/lib/api-client";
|
||||
// A care-team task. Mirrors the backend `src/types/task.ts`. Scoped to the active
|
||||
// clinic (shared across the care team).
|
||||
export type Priority = "high" | "medium" | "low";
|
||||
// Board column the task sits in. `done` mirrors `status === "done"`.
|
||||
export type TaskStatus = "todo" | "in_progress" | "done";
|
||||
|
||||
export type Task = {
|
||||
id: string;
|
||||
@@ -12,6 +14,7 @@ export type Task = {
|
||||
assigneeRole: string | null;
|
||||
due: string;
|
||||
priority: Priority;
|
||||
status: TaskStatus;
|
||||
patient: string | null;
|
||||
notes: string | null;
|
||||
done: boolean;
|
||||
@@ -28,6 +31,7 @@ export type TaskInput = {
|
||||
assigneeRole?: string | null;
|
||||
due?: string;
|
||||
priority?: Priority;
|
||||
status?: TaskStatus;
|
||||
patient?: string | null;
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user