mirror of
https://github.com/temetro/temetro.git
synced 2026-09-04 14:55:28 +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,
|
"when": 1781464772532,
|
||||||
"tag": "0016_past_maestro",
|
"tag": "0016_past_maestro",
|
||||||
"breakpoints": true
|
"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 "./settings.js";
|
||||||
export * from "./ai.js";
|
export * from "./ai.js";
|
||||||
export * from "./ai-chat.js";
|
export * from "./ai-chat.js";
|
||||||
|
export * from "./org-ai-policy.js";
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
boolean,
|
boolean,
|
||||||
index,
|
index,
|
||||||
|
integer,
|
||||||
|
jsonb,
|
||||||
pgTable,
|
pgTable,
|
||||||
text,
|
text,
|
||||||
timestamp,
|
timestamp,
|
||||||
@@ -8,6 +10,7 @@ import {
|
|||||||
uuid,
|
uuid,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
import type { MessageAttachment } from "../../types/messaging.js";
|
||||||
import { organization, user } from "./auth.js";
|
import { organization, user } from "./auth.js";
|
||||||
|
|
||||||
// A conversation between clinic staff, scoped to a clinic (organization). `name`
|
// A conversation between clinic staff, scoped to a clinic (organization). `name`
|
||||||
@@ -64,8 +67,31 @@ export const messages = pgTable(
|
|||||||
senderId: text("sender_id")
|
senderId: text("sender_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
.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(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
(t) => [index("messages_conv_idx").on(t.conversationId, t.createdAt)],
|
(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,
|
uuid,
|
||||||
} from "drizzle-orm/pg-core";
|
} 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";
|
import { organization, user } from "./auth.js";
|
||||||
|
|
||||||
// One row per care-team to-do, scoped to a clinic (organization). Shared across
|
// 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"),
|
assigneeRole: text("assignee_role"),
|
||||||
due: text("due").notNull().default("No due date"),
|
due: text("due").notNull().default("No due date"),
|
||||||
priority: text("priority").$type<TaskPriority>().notNull(),
|
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"),
|
patient: text("patient"),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
done: boolean("done").notNull().default(false),
|
done: boolean("done").notNull().default(false),
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ app.use((req, _res, next) => {
|
|||||||
// named wildcard ("*splat") rather than a bare "*".
|
// named wildcard ("*splat") rather than a bare "*".
|
||||||
app.all("/api/auth/*splat", toNodeHandler(auth));
|
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) => {
|
app.get("/health", (_req, res) => {
|
||||||
res.json({ status: "ok" });
|
res.json({ status: "ok" });
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const taskInputSchema = z.object({
|
|||||||
assigneeRole: z.enum(TASK_DEPARTMENTS).nullish(),
|
assigneeRole: z.enum(TASK_DEPARTMENTS).nullish(),
|
||||||
due: z.string().trim().max(120).default("No due date"),
|
due: z.string().trim().max(120).default("No due date"),
|
||||||
priority: z.enum(["high", "medium", "low"]).default("medium"),
|
priority: z.enum(["high", "medium", "low"]).default("medium"),
|
||||||
|
status: z.enum(["todo", "in_progress", "done"]).default("todo"),
|
||||||
patient: z.string().trim().max(200).nullish(),
|
patient: z.string().trim().max(200).nullish(),
|
||||||
notes: z.string().max(5000).nullish(),
|
notes: z.string().max(5000).nullish(),
|
||||||
});
|
});
|
||||||
|
|||||||
+12
-2
@@ -7,6 +7,7 @@ import { auth } from "./auth.js";
|
|||||||
import { env } from "./env.js";
|
import { env } from "./env.js";
|
||||||
import * as messaging from "./services/messaging.js";
|
import * as messaging from "./services/messaging.js";
|
||||||
import { createNotification } from "./services/notifications.js";
|
import { createNotification } from "./services/notifications.js";
|
||||||
|
import type { MessageAttachment } from "./types/messaging.js";
|
||||||
|
|
||||||
let io: Server | null = null;
|
let io: Server | null = null;
|
||||||
|
|
||||||
@@ -79,13 +80,21 @@ export function initRealtime(httpServer: HttpServer): Server {
|
|||||||
socket.on(
|
socket.on(
|
||||||
"message:send",
|
"message:send",
|
||||||
async (
|
async (
|
||||||
payload: { conversationId?: string; body?: string },
|
payload: {
|
||||||
|
conversationId?: string;
|
||||||
|
body?: string;
|
||||||
|
attachments?: MessageAttachment[];
|
||||||
|
},
|
||||||
ack?: Ack,
|
ack?: Ack,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const conversationId = String(payload?.conversationId ?? "");
|
const conversationId = String(payload?.conversationId ?? "");
|
||||||
const body = String(payload?.body ?? "").trim();
|
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 });
|
ack?.({ ok: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -95,6 +104,7 @@ export function initRealtime(httpServer: HttpServer): Server {
|
|||||||
userName,
|
userName,
|
||||||
conversationId,
|
conversationId,
|
||||||
body,
|
body,
|
||||||
|
attachments,
|
||||||
);
|
);
|
||||||
emitToConversation(conversationId, "message:new", message);
|
emitToConversation(conversationId, "message:new", message);
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,55 @@ import {
|
|||||||
saveAiConfig,
|
saveAiConfig,
|
||||||
toAiConfig,
|
toAiConfig,
|
||||||
} from "../services/ai/config.js";
|
} from "../services/ai/config.js";
|
||||||
|
import { getPolicy, savePolicy } from "../services/ai/policy.js";
|
||||||
import * as patients from "../services/patients.js";
|
import * as patients from "../services/patients.js";
|
||||||
|
|
||||||
export const aiRouter = Router();
|
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) ---------
|
// --- Per-user AI config (no clinic/RBAC needed, like /api/settings) ---------
|
||||||
aiRouter.get("/config", requireAuth, async (req, res, next) => {
|
aiRouter.get("/config", requireAuth, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
import { recordActivity } from "../services/activity.js";
|
import { recordActivity } from "../services/activity.js";
|
||||||
import * as aiChat from "../services/ai-chat.js";
|
import * as aiChat from "../services/ai-chat.js";
|
||||||
import { getAiSettings } from "../services/ai/config.js";
|
import { getAiSettings } from "../services/ai/config.js";
|
||||||
|
import { aiAllowedFor, getPolicy } from "../services/ai/policy.js";
|
||||||
import { resolveModel } from "../services/ai/provider.js";
|
import { resolveModel } from "../services/ai/provider.js";
|
||||||
import { createChatTools } from "../services/ai/tools.js";
|
import { createChatTools } from "../services/ai/tools.js";
|
||||||
import { createVeil } from "../services/ai/veil.js";
|
import { createVeil } from "../services/ai/veil.js";
|
||||||
@@ -33,6 +34,50 @@ export const chatRouter = Router();
|
|||||||
|
|
||||||
chatRouter.use(requireAuth, requireOrg, requirePermission({ patient: ["read"] }));
|
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 {
|
function systemPrompt(veilActive: boolean, providerLabel: string): string {
|
||||||
return [
|
return [
|
||||||
"You are temetro, a clinical assistant that helps clinicians retrieve,",
|
"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",
|
" 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",
|
" written after the clinician clicks Add. NEVER say you added/booked/created",
|
||||||
" something — say you've drafted it for their approval.",
|
" 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",
|
"- proposeInvoice: when the clinician wants to bill someone — e.g. they upload",
|
||||||
" a list of purchased medications/items. Parse it into line items",
|
" a list of purchased medications/items. Parse it into line items",
|
||||||
" {description, quantity, unitPrice} (use the prices in the document) and call",
|
" {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",
|
"- previewImport: when the clinician wants to import/migrate an existing",
|
||||||
" patient database file, or add a single patient. Parse the uploaded content",
|
" patient database file, or add a single patient. Parse the uploaded content",
|
||||||
" into our patient shape and call previewImport.",
|
" into our patient shape and call previewImport.",
|
||||||
@@ -97,6 +147,13 @@ chatRouter.post("/", async (req, res, next) => {
|
|||||||
return;
|
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 settings = await getAiSettings(req.user!.id);
|
||||||
const modelId = requestedModel || settings.defaultModel;
|
const modelId = requestedModel || settings.defaultModel;
|
||||||
const resolved = resolveModel(settings, modelId);
|
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 system = systemPrompt(veil.active, resolved.providerLabel);
|
||||||
|
|
||||||
const stream = createUIMessageStream({
|
const stream = createUIMessageStream({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { HttpError } from "../lib/http-error.js";
|
||||||
import { requireAuth, requireOrg } from "../middleware/auth.js";
|
import { requireAuth, requireOrg } from "../middleware/auth.js";
|
||||||
import { emitToConversation, emitToUser } from "../realtime.js";
|
import { emitToConversation, emitToUser } from "../realtime.js";
|
||||||
import * as service from "../services/messaging.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).
|
// Conversations are participant-scoped within the active clinic (no extra RBAC).
|
||||||
conversationsRouter.use(requireAuth, requireOrg);
|
conversationsRouter.use(requireAuth, requireOrg);
|
||||||
|
|
||||||
|
const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024; // 10mb
|
||||||
|
|
||||||
const createSchema = z.object({
|
const createSchema = z.object({
|
||||||
participantIds: z.array(z.string().min(1)).min(1),
|
participantIds: z.array(z.string().min(1)).min(1),
|
||||||
name: z.string().trim().max(120).nullish(),
|
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({
|
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)
|
// 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)
|
// POST /api/conversations — create (or reuse an existing DM)
|
||||||
conversationsRouter.post("/", async (req, res, next) => {
|
conversationsRouter.post("/", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
@@ -75,7 +148,7 @@ conversationsRouter.get("/:id/messages", async (req, res, next) => {
|
|||||||
// POST /api/conversations/:id/messages — send (REST fallback; also broadcasts)
|
// POST /api/conversations/:id/messages — send (REST fallback; also broadcasts)
|
||||||
conversationsRouter.post("/:id/messages", async (req, res, next) => {
|
conversationsRouter.post("/:id/messages", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { body } = messageSchema.parse(req.body);
|
const { body, attachments } = messageSchema.parse(req.body);
|
||||||
const conversationId = req.params.id as string;
|
const conversationId = req.params.id as string;
|
||||||
const { message, recipientIds } = await service.createMessage(
|
const { message, recipientIds } = await service.createMessage(
|
||||||
req.organizationId!,
|
req.organizationId!,
|
||||||
@@ -83,6 +156,7 @@ conversationsRouter.post("/:id/messages", async (req, res, next) => {
|
|||||||
req.user!.name,
|
req.user!.name,
|
||||||
conversationId,
|
conversationId,
|
||||||
body,
|
body,
|
||||||
|
attachments,
|
||||||
);
|
);
|
||||||
emitToConversation(conversationId, "message:new", message);
|
emitToConversation(conversationId, "message:new", message);
|
||||||
for (const recipientId of recipientIds) {
|
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 { organization } from "../../db/schema/auth.js";
|
||||||
import { appointmentInputSchema } from "../../lib/appointment-validation.js";
|
import { appointmentInputSchema } from "../../lib/appointment-validation.js";
|
||||||
import { initialsFromName } from "../../lib/initials.js";
|
import { initialsFromName } from "../../lib/initials.js";
|
||||||
|
import { inventoryInputSchema } from "../../lib/inventory-validation.js";
|
||||||
import { invoiceInputSchema } from "../../lib/invoice-validation.js";
|
import { invoiceInputSchema } from "../../lib/invoice-validation.js";
|
||||||
import { patientInputSchema } from "../../lib/patient-validation.js";
|
import { patientInputSchema } from "../../lib/patient-validation.js";
|
||||||
import { prescriptionInputSchema } from "../../lib/prescription-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({
|
proposeInvoice: tool({
|
||||||
description:
|
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.",
|
"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 {
|
import {
|
||||||
conversationParticipants,
|
conversationParticipants,
|
||||||
conversations,
|
conversations,
|
||||||
|
messageAttachments,
|
||||||
messages,
|
messages,
|
||||||
} from "../db/schema/messaging.js";
|
} from "../db/schema/messaging.js";
|
||||||
import { HttpError } from "../lib/http-error.js";
|
import { HttpError } from "../lib/http-error.js";
|
||||||
import type {
|
import type {
|
||||||
ConversationMessage,
|
ConversationMessage,
|
||||||
ConversationSummary,
|
ConversationSummary,
|
||||||
|
MessageAttachment,
|
||||||
Participant,
|
Participant,
|
||||||
} from "../types/messaging.js";
|
} from "../types/messaging.js";
|
||||||
|
|
||||||
@@ -106,6 +108,7 @@ async function buildSummaries(
|
|||||||
senderId: messages.senderId,
|
senderId: messages.senderId,
|
||||||
senderName: user.name,
|
senderName: user.name,
|
||||||
body: messages.body,
|
body: messages.body,
|
||||||
|
attachments: messages.attachments,
|
||||||
createdAt: messages.createdAt,
|
createdAt: messages.createdAt,
|
||||||
})
|
})
|
||||||
.from(messages)
|
.from(messages)
|
||||||
@@ -231,6 +234,7 @@ export async function getMessages(
|
|||||||
senderId: messages.senderId,
|
senderId: messages.senderId,
|
||||||
senderName: user.name,
|
senderName: user.name,
|
||||||
body: messages.body,
|
body: messages.body,
|
||||||
|
attachments: messages.attachments,
|
||||||
createdAt: messages.createdAt,
|
createdAt: messages.createdAt,
|
||||||
})
|
})
|
||||||
.from(messages)
|
.from(messages)
|
||||||
@@ -351,6 +355,7 @@ export async function createMessage(
|
|||||||
senderName: string,
|
senderName: string,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
body: string,
|
body: string,
|
||||||
|
attachments?: MessageAttachment[] | null,
|
||||||
): Promise<{ message: ConversationMessage; recipientIds: string[] }> {
|
): Promise<{ message: ConversationMessage; recipientIds: string[] }> {
|
||||||
if (!(await conversationInOrg(orgId, conversationId))) {
|
if (!(await conversationInOrg(orgId, conversationId))) {
|
||||||
throw new HttpError(404, "Conversation not found.");
|
throw new HttpError(404, "Conversation not found.");
|
||||||
@@ -358,10 +363,14 @@ export async function createMessage(
|
|||||||
if (!(await isParticipant(conversationId, userId))) {
|
if (!(await isParticipant(conversationId, userId))) {
|
||||||
throw new HttpError(403, "You are not part of this conversation.");
|
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 now = new Date();
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.insert(messages)
|
.insert(messages)
|
||||||
.values({ conversationId, senderId: userId, body })
|
.values({ conversationId, senderId: userId, body, attachments: list })
|
||||||
.returning();
|
.returning();
|
||||||
// Bump conversation recency and mark the sender's own read pointer.
|
// Bump conversation recency and mark the sender's own read pointer.
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -388,12 +397,74 @@ export async function createMessage(
|
|||||||
senderId: userId,
|
senderId: userId,
|
||||||
senderName,
|
senderName,
|
||||||
body: row!.body,
|
body: row!.body,
|
||||||
|
attachments: row!.attachments,
|
||||||
createdAt: row!.createdAt.toISOString(),
|
createdAt: row!.createdAt.toISOString(),
|
||||||
},
|
},
|
||||||
recipientIds: ids.filter((id) => id !== userId),
|
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(
|
export async function markRead(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ function toTask(row: TaskRow): Task {
|
|||||||
assigneeRole: row.assigneeRole,
|
assigneeRole: row.assigneeRole,
|
||||||
due: row.due,
|
due: row.due,
|
||||||
priority: row.priority,
|
priority: row.priority,
|
||||||
|
status: row.status,
|
||||||
patient: row.patient,
|
patient: row.patient,
|
||||||
notes: row.notes,
|
notes: row.notes,
|
||||||
done: row.done,
|
done: row.done,
|
||||||
@@ -74,6 +75,9 @@ export async function createTask(
|
|||||||
assigneeRole: input.assigneeRole ?? null,
|
assigneeRole: input.assigneeRole ?? null,
|
||||||
due: input.due,
|
due: input.due,
|
||||||
priority: input.priority,
|
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,
|
patient: input.patient ?? null,
|
||||||
notes: input.notes ?? null,
|
notes: input.notes ?? null,
|
||||||
createdBy: creator.id,
|
createdBy: creator.id,
|
||||||
@@ -100,7 +104,15 @@ export async function updateTask(
|
|||||||
if (patch.priority !== undefined) set.priority = patch.priority;
|
if (patch.priority !== undefined) set.priority = patch.priority;
|
||||||
if (patch.patient !== undefined) set.patient = patch.patient ?? null;
|
if (patch.patient !== undefined) set.patient = patch.patient ?? null;
|
||||||
if (patch.notes !== undefined) set.notes = patch.notes ?? 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) {
|
if (Object.keys(set).length === 0) {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
|
|||||||
@@ -5,12 +5,35 @@ export type Participant = {
|
|||||||
name: string;
|
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 = {
|
export type ConversationMessage = {
|
||||||
id: string;
|
id: string;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
senderId: string;
|
senderId: string;
|
||||||
senderName: string;
|
senderName: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
attachments?: MessageAttachment[] | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
// `lib/tasks.ts` Task type. Scoped to the active clinic (a shared care-team
|
// `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.
|
// to-do board). `patient` is an optional free-text reference for context.
|
||||||
export type TaskPriority = "high" | "medium" | "low";
|
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 = {
|
export type Task = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,6 +13,7 @@ export type Task = {
|
|||||||
assigneeRole: string | null;
|
assigneeRole: string | null;
|
||||||
due: string;
|
due: string;
|
||||||
priority: TaskPriority;
|
priority: TaskPriority;
|
||||||
|
status: TaskStatus;
|
||||||
patient: string | null;
|
patient: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
done: boolean;
|
done: boolean;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { type ReactNode, useEffect, useRef } from "react";
|
import { type ReactNode, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import { useAiAccess } from "@/lib/ai-policy";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { canAccessRoute, defaultLandingFor, useActiveRole } from "@/lib/roles";
|
import { canAccessRoute, defaultLandingFor, useActiveRole } from "@/lib/roles";
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ export function AppAuthGuard({ children }: { children: ReactNode }) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const role = useActiveRole();
|
const role = useActiveRole();
|
||||||
|
const { allowed: aiAllowed, loading: aiLoading } = useAiAccess();
|
||||||
const { data: session, isPending } = authClient.useSession();
|
const { data: session, isPending } = authClient.useSession();
|
||||||
const { data: orgs, isPending: orgsPending } =
|
const { data: orgs, isPending: orgsPending } =
|
||||||
authClient.useListOrganizations();
|
authClient.useListOrganizations();
|
||||||
@@ -52,8 +54,14 @@ export function AppAuthGuard({ children }: { children: ReactNode }) {
|
|||||||
if (!ready || role == null) return;
|
if (!ready || role == null) return;
|
||||||
if (!canAccessRoute(pathname, role)) {
|
if (!canAccessRoute(pathname, role)) {
|
||||||
router.replace(defaultLandingFor(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) {
|
if (!ready) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
Boxes,
|
||||||
CalendarPlus,
|
CalendarPlus,
|
||||||
Check,
|
Check,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
type InvoiceInput,
|
type InvoiceInput,
|
||||||
type InvoiceLineItem,
|
type InvoiceLineItem,
|
||||||
} from "@/lib/invoices";
|
} from "@/lib/invoices";
|
||||||
|
import { type InventoryInput, createInventory } from "@/lib/inventory";
|
||||||
import { type PrescriptionInput, createPrescription } from "@/lib/prescriptions";
|
import { type PrescriptionInput, createPrescription } from "@/lib/prescriptions";
|
||||||
import { type TaskInput, createTask } from "@/lib/tasks";
|
import { type TaskInput, createTask } from "@/lib/tasks";
|
||||||
import { notify } from "@/lib/toast";
|
import { notify } from "@/lib/toast";
|
||||||
@@ -33,6 +35,7 @@ export const ACTION_ICONS = {
|
|||||||
task: ClipboardList,
|
task: ClipboardList,
|
||||||
prescription: Pill,
|
prescription: Pill,
|
||||||
invoice: Receipt,
|
invoice: Receipt,
|
||||||
|
inventory: Boxes,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const ICONS = ACTION_ICONS;
|
const ICONS = ACTION_ICONS;
|
||||||
@@ -61,6 +64,18 @@ export function summarize(data: ActionPreviewData): string[] {
|
|||||||
`${items.length} item${items.length === 1 ? "" : "s"} · ${formatMoney(total)}`,
|
`${items.length} item${items.length === 1 ? "" : "s"} · ${formatMoney(total)}`,
|
||||||
].filter(Boolean);
|
].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
|
// prescription
|
||||||
return [
|
return [
|
||||||
[r.medication, r.dose].filter(Boolean).join(" "),
|
[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);
|
await createTask(data.record as TaskInput);
|
||||||
} else if (data.kind === "invoice") {
|
} else if (data.kind === "invoice") {
|
||||||
await createInvoice({ ...(data.record as InvoiceInput), source: "ai" });
|
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 {
|
} else {
|
||||||
await createPrescription({
|
await createPrescription({
|
||||||
...(data.record as PrescriptionInput),
|
...(data.record as PrescriptionInput),
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import type { Effort } from "@/lib/ai-models";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type ChatInputProps = {
|
type ChatInputProps = {
|
||||||
onSubmit: (text: string) => void;
|
onSubmit: (text: string, files: File[]) => void;
|
||||||
status: ChatStatus;
|
status: ChatStatus;
|
||||||
onStop?: () => void;
|
onStop?: () => void;
|
||||||
model: string;
|
model: string;
|
||||||
@@ -55,36 +55,16 @@ export function ChatInput({
|
|||||||
const canSend =
|
const canSend =
|
||||||
(value.trim().length > 0 || files.length > 0) && !isGenerating;
|
(value.trim().length > 0 || files.length > 0) && !isGenerating;
|
||||||
|
|
||||||
const submit = useCallback(async () => {
|
const submit = useCallback(() => {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
// Allow submitting while generating — the panel queues it (Claude-style).
|
// Allow submitting while generating — the panel queues it (Claude-style).
|
||||||
if (!trimmed && files.length === 0) {
|
if (!trimmed && files.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const parts: string[] = [];
|
// Hand the raw files to the panel; it sends them as proper attachment parts
|
||||||
if (trimmed) {
|
// (rendered as chips, not raw inlined text) and the backend extracts any
|
||||||
parts.push(trimmed);
|
// text-like content for the model.
|
||||||
}
|
onSubmit(trimmed, files);
|
||||||
// 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"));
|
|
||||||
setValue("");
|
setValue("");
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
}, [value, files, onSubmit]);
|
}, [value, files, onSubmit]);
|
||||||
@@ -234,7 +214,7 @@ export function ChatInput({
|
|||||||
<PatientFormDialog
|
<PatientFormDialog
|
||||||
key={addKey}
|
key={addKey}
|
||||||
mode="create"
|
mode="create"
|
||||||
onCreated={(fileNumber) => onSubmit(`/patient ${fileNumber}`)}
|
onCreated={(fileNumber) => onSubmit(`/patient ${fileNumber}`, [])}
|
||||||
onOpenChange={setAddOpen}
|
onOpenChange={setAddOpen}
|
||||||
open={addOpen}
|
open={addOpen}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useChat } from "@ai-sdk/react";
|
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 { AlertTriangle, Brain, ChevronDown, ShieldCheck, X } from "lucide-react";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Attachment,
|
||||||
|
AttachmentInfo,
|
||||||
|
AttachmentPreview,
|
||||||
|
Attachments,
|
||||||
|
} from "@/components/ai-elements/attachments";
|
||||||
import {
|
import {
|
||||||
ChainOfThought,
|
ChainOfThought,
|
||||||
ChainOfThoughtContent,
|
ChainOfThoughtContent,
|
||||||
@@ -83,6 +93,23 @@ import { getPatient } from "@/lib/patients";
|
|||||||
// pulls records instantly without the LLM (also works offline).
|
// pulls records instantly without the LLM (also works offline).
|
||||||
const PATIENT_COMMAND = /^\/(?:patient\s+)?(\d+)$/i;
|
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() {
|
export function ChatPanel() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [model, setModel] = useState<string>(DEFAULT_MODEL_ID);
|
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`
|
// per session before the first such send — inline (no modal). `pendingConsent`
|
||||||
// holds the message text waiting on that one-time approval.
|
// holds the message text waiting on that one-time approval.
|
||||||
const [consented, setConsented] = useState(false);
|
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
|
// 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.
|
// (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),
|
// Persisted conversation: a client-owned thread id (a fresh one per new chat),
|
||||||
// saved to the server after each exchange so history survives reloads.
|
// 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.
|
// Run the LLM agent for a message (after any Veil gate) on a given model.
|
||||||
const runAgentWith = useCallback(
|
const runAgentWith = useCallback(
|
||||||
(text: string, modelId: string) => {
|
async (text: string, modelId: string, files: File[] = []) => {
|
||||||
|
const fileParts = await Promise.all(files.map(fileToPart));
|
||||||
sendMessage(
|
sendMessage(
|
||||||
{ text },
|
{ text, files: fileParts },
|
||||||
{ body: { model: modelId, effort, threadId: threadIdRef.current } },
|
{ body: { model: modelId, effort, threadId: threadIdRef.current } },
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -161,13 +192,13 @@ export function ChatPanel() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const send = useCallback(
|
const send = useCallback(
|
||||||
async (text: string) => {
|
async (text: string, files: File[] = []) => {
|
||||||
const trimmed = text.trim();
|
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.
|
// Busy or awaiting the Veil gate → queue and auto-send when idle.
|
||||||
if (status === "submitted" || status === "streaming" || pendingConsent) {
|
if (status === "submitted" || status === "streaming" || pendingConsent) {
|
||||||
setQueued((q) => [...q, trimmed]);
|
setQueued((q) => [...q, { text: trimmed, files }]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,10 +237,10 @@ export function ChatPanel() {
|
|||||||
|
|
||||||
// Cloud model → inline Veil consent once before sending externally.
|
// Cloud model → inline Veil consent once before sending externally.
|
||||||
if (isCloudModel && !consented) {
|
if (isCloudModel && !consented) {
|
||||||
setPendingConsent(trimmed);
|
setPendingConsent({ text: trimmed, files });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
runAgentWith(trimmed, model);
|
void runAgentWith(trimmed, model, files);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
consented,
|
consented,
|
||||||
@@ -228,22 +259,22 @@ export function ChatPanel() {
|
|||||||
if (status !== "ready" || pendingConsent || queued.length === 0) return;
|
if (status !== "ready" || pendingConsent || queued.length === 0) return;
|
||||||
const [next, ...rest] = queued;
|
const [next, ...rest] = queued;
|
||||||
setQueued(rest);
|
setQueued(rest);
|
||||||
if (next) void send(next);
|
if (next) void send(next.text, next.files);
|
||||||
}, [status, pendingConsent, queued, send]);
|
}, [status, pendingConsent, queued, send]);
|
||||||
|
|
||||||
// Veil gate actions.
|
// Veil gate actions.
|
||||||
const confirmConsent = useCallback(() => {
|
const confirmConsent = useCallback(() => {
|
||||||
setConsented(true);
|
setConsented(true);
|
||||||
const text = pendingConsent;
|
const pending = pendingConsent;
|
||||||
setPendingConsent(null);
|
setPendingConsent(null);
|
||||||
if (text) runAgentWith(text, model);
|
if (pending) void runAgentWith(pending.text, model, pending.files);
|
||||||
}, [pendingConsent, runAgentWith, model]);
|
}, [pendingConsent, runAgentWith, model]);
|
||||||
|
|
||||||
const useLocalInstead = useCallback(() => {
|
const useLocalInstead = useCallback(() => {
|
||||||
setModel("ollama");
|
setModel("ollama");
|
||||||
const text = pendingConsent;
|
const pending = pendingConsent;
|
||||||
setPendingConsent(null);
|
setPendingConsent(null);
|
||||||
if (text) runAgentWith(text, "ollama");
|
if (pending) void runAgentWith(pending.text, "ollama", pending.files);
|
||||||
}, [pendingConsent, runAgentWith]);
|
}, [pendingConsent, runAgentWith]);
|
||||||
|
|
||||||
const cancelConsent = useCallback(() => setPendingConsent(null), []);
|
const cancelConsent = useCallback(() => setPendingConsent(null), []);
|
||||||
@@ -381,10 +412,13 @@ export function ChatPanel() {
|
|||||||
</span>
|
</span>
|
||||||
<QueueList>
|
<QueueList>
|
||||||
{queued.map((q, i) => (
|
{queued.map((q, i) => (
|
||||||
<QueueItem key={`${i}-${q}`}>
|
<QueueItem key={`${i}-${q.text}`}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<QueueItemIndicator />
|
<QueueItemIndicator />
|
||||||
<QueueItemContent>{q}</QueueItemContent>
|
<QueueItemContent>
|
||||||
|
{q.text ||
|
||||||
|
t("chat.queue.attachmentsOnly", { count: q.files.length })}
|
||||||
|
</QueueItemContent>
|
||||||
<QueueItemActions>
|
<QueueItemActions>
|
||||||
<QueueItemAction
|
<QueueItemAction
|
||||||
aria-label={t("chat.queue.remove")}
|
aria-label={t("chat.queue.remove")}
|
||||||
@@ -415,13 +449,16 @@ export function ChatPanel() {
|
|||||||
const firstActionPreviewIdx = message.parts.findIndex(
|
const firstActionPreviewIdx = message.parts.findIndex(
|
||||||
(p) => p.type === "data-actionPreview",
|
(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 (
|
return (
|
||||||
<Message from={message.role} key={message.id}>
|
<Message from={message.role} key={message.id}>
|
||||||
<MessageContent className="w-full">
|
<MessageContent className="w-full">
|
||||||
{steps.length > 0 ? (
|
{steps.length > 0 ? (
|
||||||
<ChainOfThought
|
<ChainOfThought
|
||||||
className="mb-1"
|
className="mb-1"
|
||||||
defaultOpen={isLast && isWorking}
|
defaultOpen={false}
|
||||||
key={`${message.id}-cot`}
|
key={`${message.id}-cot`}
|
||||||
>
|
>
|
||||||
<ChainOfThoughtHeader>{t("chat.steps")}</ChainOfThoughtHeader>
|
<ChainOfThoughtHeader>{t("chat.steps")}</ChainOfThoughtHeader>
|
||||||
@@ -478,6 +515,26 @@ export function ChatPanel() {
|
|||||||
<MessageResponse key={key}>{part.text}</MessageResponse>
|
<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") {
|
if (part.type === "data-patientCard") {
|
||||||
return (
|
return (
|
||||||
<PatientResult
|
<PatientResult
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
CommandPanel,
|
CommandPanel,
|
||||||
} from "@/components/ui/command";
|
} from "@/components/ui/command";
|
||||||
import { Kbd, KbdGroup } from "@/components/ui/kbd";
|
import { Kbd, KbdGroup } from "@/components/ui/kbd";
|
||||||
|
import { useAiAccess } from "@/lib/ai-policy";
|
||||||
import { useActiveRole, visibleNavItems } from "@/lib/roles";
|
import { useActiveRole, visibleNavItems } from "@/lib/roles";
|
||||||
|
|
||||||
type CommandPaletteContextValue = { open: () => void };
|
type CommandPaletteContextValue = { open: () => void };
|
||||||
@@ -51,6 +52,7 @@ export function CommandPaletteProvider({ children }: { children: ReactNode }) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const role = useActiveRole();
|
const role = useActiveRole();
|
||||||
|
const { allowed: aiAllowed } = useAiAccess();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -71,8 +73,11 @@ export function CommandPaletteProvider({ children }: { children: ReactNode }) {
|
|||||||
value: "pages",
|
value: "pages",
|
||||||
label: t("nav.commandGroup"),
|
label: t("nav.commandGroup"),
|
||||||
// Flatten sub-pages so e.g. "Appointments & Schedule" is reachable.
|
// Flatten sub-pages so e.g. "Appointments & Schedule" is reachable.
|
||||||
// Filtered by role so reception can't jump to clinical pages.
|
// Filtered by role so reception can't jump to clinical pages, and by
|
||||||
items: visibleNavItems(role).flatMap((item) =>
|
// 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?.length
|
||||||
? item.subs.map((sub) => ({
|
? item.subs.map((sub) => ({
|
||||||
id: sub.id,
|
id: sub.id,
|
||||||
@@ -91,7 +96,7 @@ export function CommandPaletteProvider({ children }: { children: ReactNode }) {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[t, role],
|
[t, role, aiAllowed],
|
||||||
);
|
);
|
||||||
|
|
||||||
type Group = (typeof groups)[number];
|
type Group = (typeof groups)[number];
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Mail, Plus, Search, SendHorizonal } from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
|
CalendarClock,
|
||||||
|
Download,
|
||||||
|
FileText,
|
||||||
|
Mail,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
SendHorizonal,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
type ChangeEvent,
|
||||||
type FormEvent,
|
type FormEvent,
|
||||||
Fragment,
|
Fragment,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -30,20 +40,27 @@ import {
|
|||||||
EmptyTitle,
|
EmptyTitle,
|
||||||
} from "@/components/ui/empty";
|
} from "@/components/ui/empty";
|
||||||
import { Input } from "@/components/ui/input";
|
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 { authClient } from "@/lib/auth-client";
|
||||||
import {
|
import {
|
||||||
type ConversationMessage,
|
type ConversationMessage,
|
||||||
type ConversationSummary,
|
type ConversationSummary,
|
||||||
|
type MessageAttachment,
|
||||||
type Participant,
|
type Participant,
|
||||||
createConversation,
|
createConversation,
|
||||||
|
downloadAttachment,
|
||||||
getMessages,
|
getMessages,
|
||||||
listClinicMembers,
|
listClinicMembers,
|
||||||
listConversations,
|
listConversations,
|
||||||
|
uploadAttachment,
|
||||||
} from "@/lib/messages";
|
} from "@/lib/messages";
|
||||||
import { getSocket } from "@/lib/socket";
|
import { getSocket } from "@/lib/socket";
|
||||||
import { notify } from "@/lib/toast";
|
import { notify } from "@/lib/toast";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
// Up to two-letter initials from a display name.
|
// Up to two-letter initials from a display name.
|
||||||
function initials(name: string): string {
|
function initials(name: string): string {
|
||||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
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.
|
// one sender label, one timestamp, tighter spacing.
|
||||||
const GROUP_WINDOW_MS = 5 * 60 * 1000;
|
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() {
|
export function MessagesView() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: session } = authClient.useSession();
|
const { data: session } = authClient.useSession();
|
||||||
@@ -101,6 +159,14 @@ export function MessagesView() {
|
|||||||
const [members, setMembers] = useState<Participant[]>([]);
|
const [members, setMembers] = useState<Participant[]>([]);
|
||||||
const [memberQuery, setMemberQuery] = useState("");
|
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.
|
// Refs so the socket handler (registered once) reads current values.
|
||||||
const selectedIdRef = useRef<string | null>(null);
|
const selectedIdRef = useRef<string | null>(null);
|
||||||
const myIdRef = useRef<string>("");
|
const myIdRef = useRef<string>("");
|
||||||
@@ -187,6 +253,7 @@ export function MessagesView() {
|
|||||||
setSelectedId(id);
|
setSelectedId(id);
|
||||||
selectedIdRef.current = id;
|
selectedIdRef.current = id;
|
||||||
setDraft("");
|
setDraft("");
|
||||||
|
setPending([]);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
getMessages(id)
|
getMessages(id)
|
||||||
.then(setMessages)
|
.then(setMessages)
|
||||||
@@ -204,14 +271,79 @@ export function MessagesView() {
|
|||||||
const send = (event: FormEvent) => {
|
const send = (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const text = draft.trim();
|
const text = draft.trim();
|
||||||
if (!(text && selected)) return;
|
if (!((text || pending.length > 0) && selected)) return;
|
||||||
getSocket().emit("message:send", {
|
getSocket().emit("message:send", {
|
||||||
conversationId: selected.id,
|
conversationId: selected.id,
|
||||||
body: text,
|
body: text,
|
||||||
|
attachments: pending.length > 0 ? pending : undefined,
|
||||||
});
|
});
|
||||||
setDraft("");
|
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 = () => {
|
const openCompose = () => {
|
||||||
setComposeOpen(true);
|
setComposeOpen(true);
|
||||||
setMemberQuery("");
|
setMemberQuery("");
|
||||||
@@ -426,20 +558,28 @@ export function MessagesView() {
|
|||||||
{m.senderName}
|
{m.senderName}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div
|
{m.body && (
|
||||||
className={cn(
|
<div
|
||||||
"max-w-[75%] rounded-2xl px-3 py-2 text-sm",
|
className={cn(
|
||||||
out
|
"max-w-[75%] rounded-2xl px-3 py-2 text-sm",
|
||||||
? "bg-primary text-primary-foreground"
|
out
|
||||||
: "bg-muted text-foreground",
|
? "bg-primary text-primary-foreground"
|
||||||
!startsGroup &&
|
: "bg-muted text-foreground",
|
||||||
(out ? "rounded-tr-md" : "rounded-tl-md"),
|
!startsGroup &&
|
||||||
!endsGroup &&
|
(out ? "rounded-tr-md" : "rounded-tl-md"),
|
||||||
(out ? "rounded-br-md" : "rounded-bl-md"),
|
!endsGroup &&
|
||||||
)}
|
(out ? "rounded-br-md" : "rounded-bl-md"),
|
||||||
>
|
)}
|
||||||
{m.body}
|
>
|
||||||
</div>
|
{m.body}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{m.attachments?.map((att, ai) => (
|
||||||
|
<SentAttachment
|
||||||
|
att={att}
|
||||||
|
key={`${m.id}-att-${ai}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
{endsGroup && (
|
{endsGroup && (
|
||||||
<span className="px-1 text-muted-foreground text-[11px]">
|
<span className="px-1 text-muted-foreground text-[11px]">
|
||||||
{formatTime(m.createdAt)}
|
{formatTime(m.createdAt)}
|
||||||
@@ -453,26 +593,92 @@ export function MessagesView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<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}
|
onSubmit={send}
|
||||||
>
|
>
|
||||||
<Input
|
{pending.length > 0 && (
|
||||||
aria-label={t("messages.newMessage")}
|
<div className="flex flex-wrap items-center gap-1.5 px-1 pt-1">
|
||||||
className="border-0 bg-transparent shadow-none before:hidden"
|
{pending.map((att, i) => (
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
<span
|
||||||
placeholder={t("messages.messagePlaceholder", {
|
className="flex items-center gap-1.5 rounded-lg bg-muted px-2 py-1 text-foreground text-xs"
|
||||||
name: selected.name,
|
key={`pending-${i}`}
|
||||||
})}
|
>
|
||||||
value={draft}
|
{att.kind === "file" ? (
|
||||||
/>
|
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
<Button
|
) : (
|
||||||
aria-label={t("messages.send")}
|
<CalendarClock className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
disabled={!draft.trim()}
|
)}
|
||||||
size="icon"
|
<span className="max-w-40 truncate">
|
||||||
type="submit"
|
{att.kind === "file"
|
||||||
>
|
? att.fileName
|
||||||
<SendHorizonal className="size-4" />
|
: att.appointment.name}
|
||||||
</Button>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -549,6 +755,60 @@ export function MessagesView() {
|
|||||||
</DialogPanel>
|
</DialogPanel>
|
||||||
</DialogPopup>
|
</DialogPopup>
|
||||||
</Dialog>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,13 @@ import {
|
|||||||
FieldLabel,
|
FieldLabel,
|
||||||
SettingsCard,
|
SettingsCard,
|
||||||
SettingsSection,
|
SettingsSection,
|
||||||
|
ToggleRow,
|
||||||
} from "@/components/settings/settings-parts";
|
} 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 { AI_MODELS, EFFORT_LEVELS, type Effort } from "@/lib/ai-models";
|
||||||
import {
|
import {
|
||||||
type AiConfig,
|
type AiConfig,
|
||||||
@@ -28,6 +34,7 @@ import {
|
|||||||
saveAiConfig,
|
saveAiConfig,
|
||||||
testAiConnection,
|
testAiConnection,
|
||||||
} from "@/lib/ai-settings";
|
} from "@/lib/ai-settings";
|
||||||
|
import { useActiveRole } from "@/lib/roles";
|
||||||
import { notify } from "@/lib/toast";
|
import { notify } from "@/lib/toast";
|
||||||
|
|
||||||
const PROVIDERS: ApiProvider[] = ["openai", "anthropic", "gemini"];
|
const PROVIDERS: ApiProvider[] = ["openai", "anthropic", "gemini"];
|
||||||
@@ -52,6 +59,55 @@ export function AIPanel() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [testing, setTesting] = 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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
getAiConfig()
|
getAiConfig()
|
||||||
@@ -142,6 +198,63 @@ export function AIPanel() {
|
|||||||
|
|
||||||
return (
|
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
|
<SettingsSection
|
||||||
description={t("settings.ai.modeDescription")}
|
description={t("settings.ai.modeDescription")}
|
||||||
title={t("settings.ai.modeTitle")}
|
title={t("settings.ai.modeTitle")}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useAiAccess } from "@/lib/ai-policy";
|
||||||
import { useActiveRole, visibleNavItems } from "@/lib/roles";
|
import { useActiveRole, visibleNavItems } from "@/lib/roles";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
@@ -28,10 +29,14 @@ export function DashboardSidebar() {
|
|||||||
const { state } = useSidebar();
|
const { state } = useSidebar();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const role = useActiveRole();
|
const role = useActiveRole();
|
||||||
|
const { allowed: aiAllowed } = useAiAccess();
|
||||||
const isCollapsed = state === "collapsed";
|
const isCollapsed = state === "collapsed";
|
||||||
|
|
||||||
// Hide clinical nav from non-clinical roles (e.g. reception). See lib/roles.ts.
|
// 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,
|
id: item.id,
|
||||||
title: t(item.labelKey),
|
title: t(item.labelKey),
|
||||||
icon: <item.icon className="size-4" />,
|
icon: <item.icon className="size-4" />,
|
||||||
@@ -92,7 +97,7 @@ export function DashboardSidebar() {
|
|||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent className="gap-4 px-2 py-4">
|
<SidebarContent className="gap-4 px-2 py-4">
|
||||||
<DashboardNavigation routes={dashboardRoutes} />
|
<DashboardNavigation routes={dashboardRoutes} />
|
||||||
<NavChatHistory />
|
{aiAllowed && <NavChatHistory />}
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter className="p-2">
|
<SidebarFooter className="p-2">
|
||||||
<NavUser />
|
<NavUser />
|
||||||
|
|||||||
@@ -12,9 +12,12 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet";
|
||||||
import { ROLE_LABELS } from "@/lib/access";
|
import { ROLE_LABELS } from "@/lib/access";
|
||||||
|
import type { TaskStatus } from "@/lib/tasks";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { Priority, Task } from "@/components/tasks/tasks-view";
|
import type { Priority, Task } from "@/components/tasks/tasks-view";
|
||||||
|
|
||||||
|
const STATUSES: TaskStatus[] = ["todo", "in_progress", "done"];
|
||||||
|
|
||||||
const priorityVariant: Record<Priority, "destructive" | "secondary" | "outline"> =
|
const priorityVariant: Record<Priority, "destructive" | "secondary" | "outline"> =
|
||||||
{
|
{
|
||||||
high: "destructive",
|
high: "destructive",
|
||||||
@@ -33,12 +36,12 @@ export function TaskDetailSheet({
|
|||||||
task,
|
task,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onToggle,
|
onMove,
|
||||||
}: {
|
}: {
|
||||||
task: Task | null;
|
task: Task | null;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onToggle: (id: string) => void;
|
onMove: (id: string, status: TaskStatus) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
@@ -67,9 +70,7 @@ export function TaskDetailSheet({
|
|||||||
{t("tasks.detail.status")}
|
{t("tasks.detail.status")}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-foreground">
|
<dd className="text-foreground">
|
||||||
{task.done
|
{t(`tasks.status.${task.status}`)}
|
||||||
? t("tasks.detail.completed")
|
|
||||||
: t("tasks.detail.open")}
|
|
||||||
</dd>
|
</dd>
|
||||||
<dt className="text-muted-foreground">
|
<dt className="text-muted-foreground">
|
||||||
{t("tasks.detail.assignedTo")}
|
{t("tasks.detail.assignedTo")}
|
||||||
@@ -110,16 +111,23 @@ export function TaskDetailSheet({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div className="flex flex-col gap-1.5">
|
||||||
<Button
|
<span className="text-muted-foreground text-xs">
|
||||||
onClick={() => onToggle(task.id)}
|
{t("tasks.detail.moveTo")}
|
||||||
type="button"
|
</span>
|
||||||
variant={task.done ? "outline" : "default"}
|
<div className="flex flex-wrap gap-2">
|
||||||
>
|
{STATUSES.map((s) => (
|
||||||
{task.done
|
<Button
|
||||||
? t("tasks.detail.reopen")
|
key={s}
|
||||||
: t("tasks.detail.complete")}
|
onClick={() => onMove(task.id, s)}
|
||||||
</Button>
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
variant={task.status === s ? "default" : "outline"}
|
||||||
|
>
|
||||||
|
{t(`tasks.status.${s}`)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Check, Plus } from "lucide-react";
|
import { CalendarClock, Plus } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
|
type DragEvent,
|
||||||
type FormEvent,
|
type FormEvent,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -32,6 +33,7 @@ import {
|
|||||||
type Priority,
|
type Priority,
|
||||||
type Task,
|
type Task,
|
||||||
type TaskInput,
|
type TaskInput,
|
||||||
|
type TaskStatus,
|
||||||
createTask,
|
createTask,
|
||||||
listTasks,
|
listTasks,
|
||||||
updateTask,
|
updateTask,
|
||||||
@@ -47,7 +49,8 @@ function deptLabel(role: string): string {
|
|||||||
|
|
||||||
export type { Priority, Task } from "@/lib/tasks";
|
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"> =
|
const priorityVariant: Record<Priority, "destructive" | "secondary" | "outline"> =
|
||||||
{
|
{
|
||||||
@@ -56,32 +59,12 @@ const priorityVariant: Record<Priority, "destructive" | "secondary" | "outline">
|
|||||||
low: "outline",
|
low: "outline",
|
||||||
};
|
};
|
||||||
|
|
||||||
function CheckButton({
|
// A subtle accent dot per column so the three are quick to tell apart.
|
||||||
done,
|
const columnDot: Record<TaskStatus, string> = {
|
||||||
onClick,
|
todo: "bg-muted-foreground",
|
||||||
label,
|
in_progress: "bg-primary",
|
||||||
}: {
|
done: "bg-success",
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@@ -99,10 +82,12 @@ function AddTaskDialog({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onAdd,
|
onAdd,
|
||||||
|
initialStatus,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onAdd: (task: TaskInput) => void;
|
onAdd: (task: TaskInput) => void;
|
||||||
|
initialStatus: TaskStatus;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
@@ -111,6 +96,12 @@ function AddTaskDialog({
|
|||||||
const [department, setDepartment] = useState<string>("reception");
|
const [department, setDepartment] = useState<string>("reception");
|
||||||
const [due, setDue] = useState("");
|
const [due, setDue] = useState("");
|
||||||
const [priority, setPriority] = useState<Priority>("medium");
|
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 = () => {
|
const reset = () => {
|
||||||
setTitle("");
|
setTitle("");
|
||||||
@@ -119,6 +110,7 @@ function AddTaskDialog({
|
|||||||
setDepartment("reception");
|
setDepartment("reception");
|
||||||
setDue("");
|
setDue("");
|
||||||
setPriority("medium");
|
setPriority("medium");
|
||||||
|
setStatus(initialStatus);
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = (event: FormEvent) => {
|
const submit = (event: FormEvent) => {
|
||||||
@@ -137,6 +129,7 @@ function AddTaskDialog({
|
|||||||
assigneeRole: assigneeMode === "self" ? null : department,
|
assigneeRole: assigneeMode === "self" ? null : department,
|
||||||
due: due.trim() || "No due date",
|
due: due.trim() || "No due date",
|
||||||
priority,
|
priority,
|
||||||
|
status,
|
||||||
});
|
});
|
||||||
notify.success(t("tasks.toast.addedTitle"), title.trim());
|
notify.success(t("tasks.toast.addedTitle"), title.trim());
|
||||||
reset();
|
reset();
|
||||||
@@ -208,7 +201,7 @@ function AddTaskDialog({
|
|||||||
</TabsPanel>
|
</TabsPanel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<Field label={t("tasks.dialog.due")}>
|
<Field label={t("tasks.dialog.due")}>
|
||||||
<Input
|
<Input
|
||||||
onChange={(e) => setDue(e.target.value)}
|
onChange={(e) => setDue(e.target.value)}
|
||||||
@@ -227,6 +220,19 @@ function AddTaskDialog({
|
|||||||
<option value="low">{t("tasks.priority.low")}</option>
|
<option value="low">{t("tasks.priority.low")}</option>
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</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>
|
</div>
|
||||||
</DialogPanel>
|
</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() {
|
export function TasksView() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [sheetOpen, setSheetOpen] = useState(false);
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
const [filter, setFilter] = useState<Filter>("all");
|
|
||||||
const [addOpen, setAddOpen] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
@@ -266,26 +327,29 @@ export function TasksView() {
|
|||||||
|
|
||||||
const selected = tasks.find((task) => task.id === selectedId) ?? null;
|
const selected = tasks.find((task) => task.id === selectedId) ?? null;
|
||||||
|
|
||||||
const visible = useMemo(() => {
|
const byStatus = useMemo(() => {
|
||||||
if (filter === "open") return tasks.filter((task) => !task.done);
|
const groups: Record<TaskStatus, Task[]> = {
|
||||||
if (filter === "done") return tasks.filter((task) => task.done);
|
todo: [],
|
||||||
return tasks;
|
in_progress: [],
|
||||||
}, [tasks, filter]);
|
done: [],
|
||||||
|
};
|
||||||
|
for (const task of tasks) groups[task.status]?.push(task);
|
||||||
|
return groups;
|
||||||
|
}, [tasks]);
|
||||||
|
|
||||||
// Optimistically flip done, then persist; roll back on failure.
|
// Optimistically move a task to a new column, then persist; roll back on fail.
|
||||||
const toggle = async (id: string) => {
|
const moveTask = async (id: string, status: TaskStatus) => {
|
||||||
const current = tasks.find((task) => task.id === id);
|
const current = tasks.find((task) => task.id === id);
|
||||||
if (!current) return;
|
if (!current || current.status === status) return;
|
||||||
const next = !current.done;
|
|
||||||
setTasks((prev) =>
|
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 {
|
try {
|
||||||
await updateTask(id, { done: next });
|
await updateTask(id, { status });
|
||||||
} catch {
|
} catch {
|
||||||
setTasks((prev) =>
|
setTasks((prev) => prev.map((row) => (row.id === id ? current : row)));
|
||||||
prev.map((row) => (row.id === id ? { ...row, done: current.done } : row)),
|
|
||||||
);
|
|
||||||
notify.error(
|
notify.error(
|
||||||
t("tasks.toast.updateFailedTitle"),
|
t("tasks.toast.updateFailedTitle"),
|
||||||
t("tasks.toast.updateFailedBody"),
|
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 (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6 px-6 py-10">
|
<div className="flex h-full w-full flex-col gap-6 px-6 py-8">
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-1">
|
||||||
<div>
|
<h1 className="font-semibold text-2xl tracking-tight">
|
||||||
<h1 className="font-semibold text-2xl tracking-tight">
|
{t("tasks.title")}
|
||||||
{t("tasks.title")}
|
</h1>
|
||||||
</h1>
|
<p className="text-muted-foreground text-sm">{t("tasks.subtitle")}</p>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<div className="flex w-full items-center gap-1 rounded-2xl border bg-card/30 p-1 sm:w-fit">
|
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
{(["all", "open", "done"] as Filter[]).map((f) => (
|
{COLUMNS.map((status) => {
|
||||||
<Button
|
const column = byStatus[status];
|
||||||
className="flex-1 sm:flex-none"
|
return (
|
||||||
key={f}
|
<section
|
||||||
onClick={() => setFilter(f)}
|
className={cn(
|
||||||
size="sm"
|
"flex min-h-0 flex-col gap-3 rounded-2xl border bg-card/30 p-3 transition-colors",
|
||||||
type="button"
|
dragOver === status && "border-ring bg-accent/40",
|
||||||
variant={filter === f ? "secondary" : "ghost"}
|
)}
|
||||||
>
|
key={status}
|
||||||
{t(`tasks.filters.${f}`)}
|
onDragLeave={() => setDragOver((s) => (s === status ? null : s))}
|
||||||
</Button>
|
onDragOver={(e) => {
|
||||||
))}
|
e.preventDefault();
|
||||||
</div>
|
setDragOver(status);
|
||||||
|
}}
|
||||||
<div className="divide-y divide-border overflow-hidden rounded-2xl border bg-card/30">
|
onDrop={(e) => handleDrop(e, status)}
|
||||||
{visible.length === 0 ? (
|
>
|
||||||
<p className="px-4 py-10 text-center text-muted-foreground text-sm">
|
<div className="flex items-center justify-between gap-2 px-1">
|
||||||
{t("tasks.empty")}
|
<div className="flex items-center gap-2">
|
||||||
</p>
|
<span
|
||||||
) : (
|
className={cn("size-2 rounded-full", columnDot[status])}
|
||||||
visible.map((task) => (
|
/>
|
||||||
<div className="flex items-center gap-3 px-4 py-3" key={task.id}>
|
<span className="font-medium text-sm">
|
||||||
<CheckButton
|
{t(`tasks.status.${status}`)}
|
||||||
done={task.done}
|
</span>
|
||||||
label={
|
<span className="text-muted-foreground text-xs">
|
||||||
task.done ? t("tasks.markNotDone") : t("tasks.markDone")
|
{column.length}
|
||||||
}
|
</span>
|
||||||
onClick={() => toggle(task.id)}
|
</div>
|
||||||
/>
|
<Button
|
||||||
<button
|
aria-label={t("tasks.board.addTask")}
|
||||||
className="flex min-w-0 flex-1 flex-col text-left"
|
onClick={() => openAdd(status)}
|
||||||
onClick={() => openTask(task.id)}
|
size="icon-sm"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
variant="ghost"
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"truncate text-sm",
|
|
||||||
task.done
|
|
||||||
? "text-muted-foreground line-through"
|
|
||||||
: "font-medium text-foreground",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{task.title}
|
<Plus className="size-4" />
|
||||||
</span>
|
</Button>
|
||||||
<span className="truncate text-muted-foreground text-xs">
|
</div>
|
||||||
{task.assigneeRole
|
|
||||||
? t("tasks.list.forDept", {
|
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
|
||||||
dept: deptLabel(task.assigneeRole),
|
{column.length === 0 ? (
|
||||||
})
|
<p className="px-1 py-6 text-center text-muted-foreground text-xs">
|
||||||
: t("tasks.list.personal")}
|
{t("tasks.board.emptyColumn")}
|
||||||
{task.createdByName
|
</p>
|
||||||
? ` · ${t("tasks.list.byCreator", { name: task.createdByName })}`
|
) : (
|
||||||
: ""}
|
column.map((task) => (
|
||||||
</span>
|
<TaskCard
|
||||||
</button>
|
key={task.id}
|
||||||
<Badge
|
onDragStart={() => setDragId(task.id)}
|
||||||
className="shrink-0"
|
onOpen={() => openTask(task.id)}
|
||||||
variant={priorityVariant[task.priority]}
|
task={task}
|
||||||
>
|
/>
|
||||||
{t(`tasks.priority.${task.priority}`)}
|
))
|
||||||
</Badge>
|
)}
|
||||||
</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
<AddTaskDialog onAdd={addTask} onOpenChange={setAddOpen} open={addOpen} />
|
<AddTaskDialog
|
||||||
|
initialStatus={addStatus}
|
||||||
|
onAdd={addTask}
|
||||||
|
onOpenChange={setAddOpen}
|
||||||
|
open={addOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
<TaskDetailSheet
|
<TaskDetailSheet
|
||||||
|
onMove={moveTask}
|
||||||
onOpenChange={setSheetOpen}
|
onOpenChange={setSheetOpen}
|
||||||
onToggle={toggle}
|
|
||||||
open={sheetOpen}
|
open={sheetOpen}
|
||||||
task={selected}
|
task={selected}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ export type ActionPreviewKind =
|
|||||||
| "appointment"
|
| "appointment"
|
||||||
| "task"
|
| "task"
|
||||||
| "prescription"
|
| "prescription"
|
||||||
| "invoice";
|
| "invoice"
|
||||||
|
| "inventory";
|
||||||
export type ActionPreviewData = {
|
export type ActionPreviewData = {
|
||||||
token: string;
|
token: string;
|
||||||
kind: ActionPreviewKind;
|
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.",
|
"empty": "No tasks here.",
|
||||||
"markDone": "Mark as done",
|
"markDone": "Mark as done",
|
||||||
"markNotDone": "Mark as not 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": {
|
"filters": {
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"open": "Open",
|
"open": "Open",
|
||||||
@@ -566,6 +576,7 @@
|
|||||||
"due": "Due",
|
"due": "Due",
|
||||||
"duePlaceholder": "e.g. Today",
|
"duePlaceholder": "e.g. Today",
|
||||||
"priorityLabel": "Priority",
|
"priorityLabel": "Priority",
|
||||||
|
"statusLabel": "Status",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"add": "Add task"
|
"add": "Add task"
|
||||||
},
|
},
|
||||||
@@ -588,7 +599,8 @@
|
|||||||
"patient": "Patient",
|
"patient": "Patient",
|
||||||
"details": "Details",
|
"details": "Details",
|
||||||
"reopen": "Reopen task",
|
"reopen": "Reopen task",
|
||||||
"complete": "Mark complete"
|
"complete": "Mark complete",
|
||||||
|
"moveTo": "Move to"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"needSubjectTitle": "Add a subject",
|
"needSubjectTitle": "Add a subject",
|
||||||
@@ -626,7 +638,25 @@
|
|||||||
"noMembers": "No other clinic members yet. Invite colleagues from Settings → Care team."
|
"noMembers": "No other clinic members yet. Invite colleagues from Settings → Care team."
|
||||||
},
|
},
|
||||||
"startFailedTitle": "Couldn't start conversation",
|
"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": {
|
"analysis": {
|
||||||
"title": "Analysis",
|
"title": "Analysis",
|
||||||
@@ -795,7 +825,8 @@
|
|||||||
},
|
},
|
||||||
"queue": {
|
"queue": {
|
||||||
"label": "Queued · {{count}}",
|
"label": "Queued · {{count}}",
|
||||||
"remove": "Remove from queue"
|
"remove": "Remove from queue",
|
||||||
|
"attachmentsOnly": "{{count}} attachment(s)"
|
||||||
},
|
},
|
||||||
"veil": {
|
"veil": {
|
||||||
"title": "Veil",
|
"title": "Veil",
|
||||||
@@ -810,14 +841,17 @@
|
|||||||
"appointment": "Proposed appointment",
|
"appointment": "Proposed appointment",
|
||||||
"task": "Proposed task",
|
"task": "Proposed task",
|
||||||
"prescription": "Proposed prescription",
|
"prescription": "Proposed prescription",
|
||||||
"invoice": "Proposed invoice"
|
"invoice": "Proposed invoice",
|
||||||
|
"inventory": "Proposed inventory"
|
||||||
},
|
},
|
||||||
"kind": {
|
"kind": {
|
||||||
"appointment": "Appointment added.",
|
"appointment": "Appointment added.",
|
||||||
"task": "Task added.",
|
"task": "Task added.",
|
||||||
"prescription": "Prescription added.",
|
"prescription": "Prescription added.",
|
||||||
"invoice": "Invoice added."
|
"invoice": "Invoice added.",
|
||||||
|
"inventory": "Inventory updated."
|
||||||
},
|
},
|
||||||
|
"inventoryItems": "{{count}} item(s)",
|
||||||
"approve": "Add",
|
"approve": "Add",
|
||||||
"adding": "Adding…",
|
"adding": "Adding…",
|
||||||
"discard": "Discard",
|
"discard": "Discard",
|
||||||
@@ -1258,6 +1292,19 @@
|
|||||||
"backupDesc": "Export an encrypted backup of your signing key to restore it on a new device."
|
"backupDesc": "Export an encrypted backup of your signing key to restore it on a new device."
|
||||||
},
|
},
|
||||||
"ai": {
|
"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",
|
"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.",
|
"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",
|
"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`.
|
// Messaging shapes. Mirror the backend `src/types/messaging.ts`.
|
||||||
export type Participant = {
|
export type Participant = {
|
||||||
@@ -6,12 +6,35 @@ export type Participant = {
|
|||||||
name: string;
|
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 = {
|
export type ConversationMessage = {
|
||||||
id: string;
|
id: string;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
senderId: string;
|
senderId: string;
|
||||||
senderName: string;
|
senderName: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
attachments?: MessageAttachment[] | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -57,13 +80,72 @@ export function getMessages(
|
|||||||
export function sendMessageRest(
|
export function sendMessageRest(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
body: string,
|
body: string,
|
||||||
|
attachments?: MessageAttachment[],
|
||||||
): Promise<ConversationMessage> {
|
): Promise<ConversationMessage> {
|
||||||
return apiFetch<ConversationMessage>(
|
return apiFetch<ConversationMessage>(
|
||||||
`/api/conversations/${conversationId}/messages`,
|
`/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> {
|
export function markConversationRead(conversationId: string): Promise<void> {
|
||||||
return apiFetch<void>(`/api/conversations/${conversationId}/read`, {
|
return apiFetch<void>(`/api/conversations/${conversationId}/read`, {
|
||||||
method: "POST",
|
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
|
// A care-team task. Mirrors the backend `src/types/task.ts`. Scoped to the active
|
||||||
// clinic (shared across the care team).
|
// clinic (shared across the care team).
|
||||||
export type Priority = "high" | "medium" | "low";
|
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 = {
|
export type Task = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -12,6 +14,7 @@ export type Task = {
|
|||||||
assigneeRole: string | null;
|
assigneeRole: string | null;
|
||||||
due: string;
|
due: string;
|
||||||
priority: Priority;
|
priority: Priority;
|
||||||
|
status: TaskStatus;
|
||||||
patient: string | null;
|
patient: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
done: boolean;
|
done: boolean;
|
||||||
@@ -28,6 +31,7 @@ export type TaskInput = {
|
|||||||
assigneeRole?: string | null;
|
assigneeRole?: string | null;
|
||||||
due?: string;
|
due?: string;
|
||||||
priority?: Priority;
|
priority?: Priority;
|
||||||
|
status?: TaskStatus;
|
||||||
patient?: string | null;
|
patient?: string | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user