From 37d52209fc21e81eac014fcb55a05e5129b9f0b5 Mon Sep 17 00:00:00 2001 From: Atharv Date: Wed, 22 Apr 2026 02:12:38 +0530 Subject: [PATCH 1/2] Basic end-to-end ballooning implementation --- .../app/modules/quality/quality.service.ts | 1027 ++++++- .../ui/Ballooning/BalloonDiagramEditor.tsx | 2452 ++++++++++++++--- .../quality/ui/Ballooning/BallooningForm.tsx | 60 +- .../exportBallooningPdfWithOverlays.ts | 247 ++ .../modules/quality/ui/Ballooning/index.ts | 7 +- apps/erp/app/routes/api+/mcp+/lib/server.ts | 2 +- .../app/routes/api+/mcp+/lib/tools/quality.ts | 199 ++ .../x+/ballooning-diagram+/$id.save.tsx | 413 ++- .../app/routes/x+/ballooning-diagram+/$id.tsx | 58 +- apps/erp/app/ssr-shims/canvas-stub.cjs | 62 + apps/erp/package.json | 4 + apps/erp/vite.config.ts | 6 + llm/cache/mcp-tools-reference.md | 147 +- package-lock.json | 231 ++ .../20260421120000_ballooning-tables.sql | 391 +++ packages/react/src/Editor/Editor.tsx | 35 +- 16 files changed, 4818 insertions(+), 523 deletions(-) create mode 100644 apps/erp/app/modules/quality/ui/Ballooning/exportBallooningPdfWithOverlays.ts create mode 100644 apps/erp/app/ssr-shims/canvas-stub.cjs create mode 100644 packages/database/supabase/migrations/20260421120000_ballooning-tables.sql diff --git a/apps/erp/app/modules/quality/quality.service.ts b/apps/erp/app/modules/quality/quality.service.ts index 28d6c8079..854a902ff 100644 --- a/apps/erp/app/modules/quality/quality.service.ts +++ b/apps/erp/app/modules/quality/quality.service.ts @@ -1771,48 +1771,166 @@ export async function upsertRisk( } // ─── Ballooning Diagrams ───────────────────────────────────────────────────── -// Stored in qualityDocument with tags: ["ballooning"] -// content JSON shape: -// { drawingNumber, revision, pdfUrl, annotations: [{id, balloonNumber, x, y, page}], features: [{...}] } +// Stored in ballooningDrawing +// This first step intentionally de-links ballooning diagrams from qualityDocument content. + +function toStoragePath(pdfUrl?: string | null) { + if (!pdfUrl) return null; + const previewPrefix = "/file/preview/private/"; + if (pdfUrl.startsWith(previewPrefix)) { + return pdfUrl.slice(previewPrefix.length); + } + return pdfUrl; +} + +function toPreviewUrl(storagePath?: string | null) { + if (!storagePath) return null; + return storagePath.startsWith("/file/preview/private/") + ? storagePath + : `/file/preview/private/${storagePath}`; +} + +function fileNameFromPath(storagePath?: string | null) { + if (!storagePath) return "drawing.pdf"; + return storagePath.split("/").at(-1) ?? "drawing.pdf"; +} + +function mapBallooningDrawingToDiagram(row: Record) { + const drawingNumber = (row.drawingNumber as string | null) ?? null; + return { + id: String(row.id), + name: String(drawingNumber ?? row.fileName ?? "Untitled Diagram"), + companyId: String(row.companyId), + createdBy: String(row.createdBy), + updatedBy: (row.updatedBy as string | null) ?? null, + createdAt: String(row.createdAt), + updatedAt: (row.updatedAt as string | null) ?? null, + qualityDocumentId: String(row.qualityDocumentId), + content: { + drawingNumber, + revision: (row.revision as string | null) ?? null, + pdfUrl: toPreviewUrl((row.storagePath as string | null) ?? null), + annotations: [], + features: [] + } + }; +} export async function getBallooningDiagrams( client: SupabaseClient, companyId: string, args?: { search: string | null } & GenericQueryFilters ) { - let query = client - .from("qualityDocument") + const drawingClient = client as unknown as { + from: (table: string) => { + select: ( + columns: string, + options?: { count?: "exact" } + ) => { + eq: ( + column: string, + value: unknown + ) => { + is: ( + column: string, + value: null + ) => { + or: (filter: string) => Promise<{ + data: Record[] | null; + count: number | null; + error: unknown; + }>; + order: ( + column: string, + opts: { ascending: boolean } + ) => Promise<{ + data: Record[] | null; + count: number | null; + error: unknown; + }>; + }; + }; + }; + }; + }; + + let query = drawingClient + .from("ballooningDrawing") .select("*", { count: "exact" }) .eq("companyId", companyId) - .contains("tags", ["ballooning"]); + .is("deletedAt", null); - if (args?.search) { - query = query.ilike("name", `%${args.search}%`); - } - - query = setGenericQueryFilters(query, args ?? {}, [ - { column: "name", ascending: true } - ]); + const result = args?.search + ? await query.or( + `drawingNumber.ilike.%${args.search}%,fileName.ilike.%${args.search}%` + ) + : await query.order("drawingNumber", { ascending: true }); - return query; + return { + data: (result.data ?? []).map(mapBallooningDrawingToDiagram), + count: result.count ?? 0, + error: result.error + }; } export async function getBallooningDiagram( client: SupabaseClient, id: string ) { - return client.from("qualityDocument").select("*").eq("id", id).single(); + const drawingClient = client as unknown as { + from: (table: string) => { + select: (columns: string) => { + eq: ( + column: string, + value: unknown + ) => { + is: ( + column: string, + value: null + ) => { + single: () => Promise<{ + data: Record | null; + error: unknown; + }>; + }; + }; + }; + }; + }; + + const result = await drawingClient + .from("ballooningDrawing") + .select("*") + .eq("id", id) + .is("deletedAt", null) + .single(); + + return { + data: result.data ? mapBallooningDrawingToDiagram(result.data) : null, + error: result.error + }; } export async function upsertBallooningDiagram( client: SupabaseClient, - diagram: Omit, "id"> & { - id?: string; - companyId: string; - createdBy: string; - updatedBy?: string; - features?: string; - } + diagram: + | (Omit, "id"> & { + id?: undefined; + companyId: string; + createdBy: string; + pageCount?: number; + defaultPageWidth?: number; + defaultPageHeight?: number; + }) + | (Omit, "id"> & { + id: string; + companyId?: string; + createdBy: string; + updatedBy?: string; + pageCount?: number; + defaultPageWidth?: number; + defaultPageHeight?: number; + }) ) { const { id, @@ -1820,52 +1938,869 @@ export async function upsertBallooningDiagram( drawingNumber, revision, pdfUrl, - annotations, - features, + pageCount, + defaultPageWidth, + defaultPageHeight, companyId, createdBy, updatedBy } = diagram; - const content = { - drawingNumber: drawingNumber ?? null, - revision: revision ?? null, - pdfUrl: pdfUrl ?? null, - annotations: annotations ? JSON.parse(annotations) : [], - features: features ? JSON.parse(features) : [] + const drawingClient = client as unknown as { + from: (table: string) => { + update: (payload: Record) => { + eq: ( + column: string, + value: unknown + ) => { + select: (columns: string) => { + single: () => Promise<{ + data: { id: string } | null; + error: unknown; + }>; + }; + }; + }; + insert: (payload: Record) => { + select: (columns: string) => { + single: () => Promise<{ + data: { id: string } | null; + error: unknown; + }>; + }; + }; + }; }; + const storagePath = toStoragePath(pdfUrl); + if (id) { - return client + const existingResult = await drawingClient + .from("ballooningDrawing") + .select("*") + .eq("id", id) + .is("deletedAt", null) + .single(); + + const existing = existingResult.data; + if (!existing) { + return { + data: null, + error: { + message: "Ballooning drawing not found" + } + }; + } + + await client .from("qualityDocument") .update({ name, - content, updatedBy: updatedBy ?? createdBy, updatedAt: new Date().toISOString() }) + .eq("id", String(existing.qualityDocumentId)); + + const updatePayload: Record = { + drawingNumber: drawingNumber ?? name, + revision: revision ?? null, + updatedBy: updatedBy ?? createdBy, + updatedAt: new Date().toISOString() + }; + + if (storagePath) { + updatePayload.storagePath = storagePath; + updatePayload.fileName = fileNameFromPath(storagePath); + } + if (pageCount && pageCount > 0) { + updatePayload.pageCount = pageCount; + } + if (defaultPageWidth && defaultPageWidth > 0) { + updatePayload.defaultPageWidth = defaultPageWidth; + } + if (defaultPageHeight && defaultPageHeight > 0) { + updatePayload.defaultPageHeight = defaultPageHeight; + } + + return drawingClient + .from("ballooningDrawing") + .update(updatePayload) .eq("id", id) .select("id") .single(); - } else { - return client - .from("qualityDocument") - .insert({ - name, - content, - companyId, - createdBy, - tags: ["ballooning"], - status: "Active" - }) - .select("id") - .single(); } + + if (!companyId) { + return { + data: null, + error: { message: "companyId is required to create ballooning drawing" } + }; + } + + if (!storagePath) { + return { + data: null, + error: { message: "PDF upload is required to create ballooning diagram" } + }; + } + + const qualityDocumentInsert = await client + .from("qualityDocument") + .insert({ + name, + companyId, + createdBy, + tags: ["ballooning"], + status: "Active", + content: {} + }) + .select("id") + .single(); + + if (qualityDocumentInsert.error || !qualityDocumentInsert.data?.id) { + return { + data: null, + error: + qualityDocumentInsert.error ?? + ({ message: "Failed to create quality document" } as const) + }; + } + + return drawingClient + .from("ballooningDrawing") + .insert({ + companyId, + qualityDocumentId: qualityDocumentInsert.data.id, + drawingNumber: drawingNumber ?? name, + revision: revision ?? null, + version: 0, + storagePath, + fileName: fileNameFromPath(storagePath), + ...(pageCount && pageCount > 0 ? { pageCount } : {}), + ...(defaultPageWidth && defaultPageWidth > 0 ? { defaultPageWidth } : {}), + ...(defaultPageHeight && defaultPageHeight > 0 + ? { defaultPageHeight } + : {}), + uploadedBy: createdBy, + createdBy + }) + .select("id") + .single(); } export async function deleteBallooningDiagram( client: SupabaseClient, id: string ) { - return client.from("qualityDocument").delete().eq("id", id); + const drawingClient = client as unknown as { + from: (table: string) => { + update: (payload: Record) => { + eq: ( + column: string, + value: unknown + ) => Promise<{ + error: unknown; + }>; + }; + }; + }; + + const existingResult = await drawingClient + .from("ballooningDrawing") + .select("*") + .eq("id", id) + .is("deletedAt", null) + .single(); + + if (!existingResult.data) { + return { + error: { message: "Ballooning drawing not found" } + }; + } + + await client + .from("qualityDocument") + .update({ + status: "Archived", + updatedAt: new Date().toISOString() + }) + .eq("id", String(existingResult.data.qualityDocumentId)); + + return drawingClient + .from("ballooningDrawing") + .update({ deletedAt: new Date().toISOString() }) + .eq("id", id); +} + +export async function getBallooningSelectors( + client: SupabaseClient, + drawingId: string +) { + const drawingClient = client as unknown as { + from: (table: string) => { + select: (columns: string) => { + eq: ( + column: string, + value: unknown + ) => { + is: ( + column: string, + value: null + ) => { + order: ( + column: string, + opts: { ascending: boolean } + ) => Promise<{ + data: Record[] | null; + error: unknown; + }>; + }; + }; + }; + }; + }; + + return drawingClient + .from("ballooningSelector") + .select("*") + .eq("drawingId", drawingId) + .is("deletedAt", null) + .order("createdAt", { ascending: true }); +} + +export async function createBallooningSelectors( + client: SupabaseClient, + args: { + drawingId: string; + companyId: string; + createdBy: string; + selectors: { + pageNumber: number; + xCoordinate: number; + yCoordinate: number; + width: number; + height: number; + }[]; + } +) { + const drawingClient = client as unknown as { + from: (table: string) => { + insert: (payload: Record[]) => { + select: (columns: string) => Promise<{ + data: Record[] | null; + error: unknown; + }>; + }; + }; + }; + + if (args.selectors.length === 0) { + return { data: [], error: null }; + } + + return drawingClient + .from("ballooningSelector") + .insert( + args.selectors.map((s) => ({ + drawingId: args.drawingId, + companyId: args.companyId, + pageNumber: s.pageNumber, + xCoordinate: s.xCoordinate, + yCoordinate: s.yCoordinate, + width: s.width, + height: s.height, + createdBy: args.createdBy, + updatedBy: args.createdBy + })) + ) + .select("*"); +} + +export async function updateBallooningSelectors( + client: SupabaseClient, + args: { + drawingId: string; + companyId: string; + updatedBy: string; + selectors: { + id: string; + pageNumber?: number; + xCoordinate?: number; + yCoordinate?: number; + width?: number; + height?: number; + }[]; + } +) { + const drawingClient = client as unknown as { + from: (table: string) => { + update: (payload: Record) => { + eq: ( + column: string, + value: unknown + ) => { + select: (columns: string) => Promise<{ + data: Record[] | null; + error: unknown; + }>; + }; + }; + }; + }; + + if (args.selectors.length === 0) { + return { data: [], error: null }; + } + + const updated: Record[] = []; + for (const selector of args.selectors) { + const payload: Record = { + updatedBy: args.updatedBy, + updatedAt: new Date().toISOString() + }; + if (typeof selector.pageNumber === "number") { + payload.pageNumber = selector.pageNumber; + } + if (typeof selector.xCoordinate === "number") { + payload.xCoordinate = selector.xCoordinate; + } + if (typeof selector.yCoordinate === "number") { + payload.yCoordinate = selector.yCoordinate; + } + if (typeof selector.width === "number") { + payload.width = selector.width; + } + if (typeof selector.height === "number") { + payload.height = selector.height; + } + + const result = await drawingClient + .from("ballooningSelector") + .update(payload) + .eq("id", selector.id) + .eq("drawingId", args.drawingId) + .eq("companyId", args.companyId) + .select("*"); + + if (result.error) { + return { data: null, error: result.error }; + } + if (result.data && result.data[0]) { + updated.push(result.data[0]); + } + } + + return { data: updated, error: null }; +} + +export async function deleteBallooningSelectors( + client: SupabaseClient, + args: { + drawingId: string; + companyId: string; + updatedBy: string; + ids: string[]; + } +) { + const drawingClient = client as unknown as { + from: (table: string) => { + update: (payload: Record) => { + in: ( + column: string, + values: string[] + ) => { + eq: ( + column: string, + value: unknown + ) => { + eq: ( + column: string, + value: unknown + ) => { + is: ( + column: string, + value: null + ) => Promise<{ + data: Record[] | null; + error: unknown; + }>; + }; + }; + }; + }; + }; + }; + + if (args.ids.length === 0) { + return { data: [], error: null }; + } + + return drawingClient + .from("ballooningSelector") + .update({ + deletedAt: new Date().toISOString(), + updatedBy: args.updatedBy, + updatedAt: new Date().toISOString() + }) + .in("id", args.ids) + .eq("drawingId", args.drawingId) + .eq("companyId", args.companyId) + .is("deletedAt", null) + .select("id"); +} + +function clamp01(n: number) { + return Math.max(0, Math.min(1, n)); +} + +type BalloonRect = { + pageNumber: number; + x: number; + y: number; + width: number; + height: number; +}; + +function overlaps(a: BalloonRect, b: BalloonRect) { + if (a.pageNumber !== b.pageNumber) return false; + return !( + a.x + a.width <= b.x || + b.x + b.width <= a.x || + a.y + a.height <= b.y || + b.y + b.height <= a.y + ); +} + +function inBounds(rect: BalloonRect) { + return ( + rect.x >= 0 && + rect.y >= 0 && + rect.x + rect.width <= 1 && + rect.y + rect.height <= 1 + ); +} + +function clampRectToBounds(rect: BalloonRect): BalloonRect { + const x = clamp01(Math.min(rect.x, 1 - rect.width)); + const y = clamp01(Math.min(rect.y, 1 - rect.height)); + return { ...rect, x, y }; +} + +export async function getBallooningBalloons( + client: SupabaseClient, + drawingId: string +) { + const drawingClient = client as unknown as { + from: (table: string) => { + select: (columns: string) => { + eq: ( + column: string, + value: unknown + ) => { + is: ( + column: string, + value: null + ) => { + order: ( + column: string, + opts: { ascending: boolean } + ) => Promise<{ + data: Record[] | null; + error: unknown; + count?: number | null; + }>; + select?: never; + }; + select?: never; + }; + }; + }; + }; + + return drawingClient + .from("ballooningBalloon") + .select("*") + .eq("drawingId", drawingId) + .is("deletedAt", null) + .order("createdAt", { ascending: true }); +} + +export async function createBalloonsForSelectors( + client: SupabaseClient, + args: { + drawingId: string; + companyId: string; + createdBy: string; + selectors: { + id: string; + pageNumber: number; + xCoordinate: number; + yCoordinate: number; + width: number; + height: number; + }[]; + } +) { + const drawingClient = client as unknown as { + from: (table: string) => { + select: ( + columns: string, + opts?: { count?: "exact" } + ) => { + eq: ( + column: string, + value: unknown + ) => { + is: ( + column: string, + value: null + ) => Promise<{ + data: Record[] | null; + error: unknown; + count?: number | null; + }>; + }; + }; + insert: (payload: Record[]) => Promise<{ + data: Record[] | null; + error: unknown; + }>; + }; + }; + + if (args.selectors.length === 0) { + return { data: [], error: null }; + } + + const existing = await drawingClient + .from("ballooningBalloon") + .select("id, xCoordinate, yCoordinate, data", { count: "exact" }) + .eq("drawingId", args.drawingId) + .is("deletedAt", null); + + if (existing.error) { + return { data: null, error: existing.error }; + } + + let nextLabel = (existing.count ?? 0) + 1; + const balloonWidth = 0.04; + const balloonHeight = 0.04; + const offset = 0.02; + const occupied: BalloonRect[] = (existing.data ?? []) + .map((b) => { + const pageNumber = Number( + ( + b.data as + | { + pageNumber?: unknown; + } + | null + | undefined + )?.pageNumber ?? 1 + ); + + return { + pageNumber, + x: Number(b.xCoordinate ?? 0), + y: Number(b.yCoordinate ?? 0), + width: balloonWidth, + height: balloonHeight + }; + }) + .filter((r) => Number.isFinite(r.x) && Number.isFinite(r.y)); + + return drawingClient.from("ballooningBalloon").insert( + args.selectors.map((s) => { + const anchorX = clamp01(s.xCoordinate + s.width / 2); + const anchorY = clamp01(s.yCoordinate + s.height / 2); + const candidates: BalloonRect[] = [ + { + pageNumber: s.pageNumber, + x: s.xCoordinate + s.width + offset, + y: s.yCoordinate, + width: balloonWidth, + height: balloonHeight + }, + { + pageNumber: s.pageNumber, + x: s.xCoordinate + s.width + offset, + y: s.yCoordinate - balloonHeight - offset, + width: balloonWidth, + height: balloonHeight + }, + { + pageNumber: s.pageNumber, + x: s.xCoordinate + s.width + offset, + y: s.yCoordinate + s.height + offset, + width: balloonWidth, + height: balloonHeight + }, + { + pageNumber: s.pageNumber, + x: s.xCoordinate, + y: s.yCoordinate - balloonHeight - offset, + width: balloonWidth, + height: balloonHeight + }, + { + pageNumber: s.pageNumber, + x: s.xCoordinate, + y: s.yCoordinate + s.height + offset, + width: balloonWidth, + height: balloonHeight + }, + { + pageNumber: s.pageNumber, + x: s.xCoordinate - balloonWidth - offset, + y: s.yCoordinate, + width: balloonWidth, + height: balloonHeight + } + ]; + + const placed = + candidates.find( + (candidate) => + inBounds(candidate) && + !occupied.some((other) => overlaps(candidate, other)) + ) ?? clampRectToBounds(candidates[0]!); + + occupied.push(placed); + const label = String(nextLabel++); + + return { + selectorId: s.id, + drawingId: args.drawingId, + companyId: args.companyId, + label, + xCoordinate: placed.x, + yCoordinate: placed.y, + anchorX, + anchorY, + data: { + source: "selector-auto", + pageNumber: s.pageNumber, + placement: { + width: balloonWidth, + height: balloonHeight, + offset + }, + selector: { + x: s.xCoordinate, + y: s.yCoordinate, + width: s.width, + height: s.height + } + }, + createdBy: args.createdBy, + updatedBy: args.createdBy + }; + }) + ); +} + +export async function createBallooningBalloonsFromPayload( + client: SupabaseClient, + args: { + drawingId: string; + companyId: string; + createdBy: string; + selectorIdMap: Record; + balloons: Array<{ + tempSelectorId: string; + label: string; + xCoordinate: number; + yCoordinate: number; + anchorX: number; + anchorY: number; + data: Record; + description?: string | null; + }>; + } +) { + const drawingClient = client as unknown as { + from: (table: string) => { + insert: (payload: Record[]) => Promise<{ + data: Record[] | null; + error: unknown; + }>; + }; + }; + + if (args.balloons.length === 0) { + return { data: [], error: null }; + } + + const rows = args.balloons.map((b) => { + const selectorId = args.selectorIdMap[b.tempSelectorId]; + if (!selectorId) { + return null; + } + return { + selectorId, + drawingId: args.drawingId, + companyId: args.companyId, + label: b.label, + xCoordinate: b.xCoordinate, + yCoordinate: b.yCoordinate, + anchorX: b.anchorX, + anchorY: b.anchorY, + description: b.description ?? null, + data: b.data, + createdBy: args.createdBy, + updatedBy: args.createdBy + }; + }); + + if (rows.some((r) => r === null)) { + return { + data: null, + error: new Error("Missing selector mapping for one or more balloons") + }; + } + + return drawingClient + .from("ballooningBalloon") + .insert(rows as Record[]); +} + +export async function updateBallooningBalloons( + client: SupabaseClient, + args: { + drawingId: string; + companyId: string; + updatedBy: string; + balloons: Array<{ + id: string; + label?: string; + xCoordinate?: number; + yCoordinate?: number; + anchorX?: number; + anchorY?: number; + data?: Record; + description?: string | null; + }>; + } +) { + const drawingClient = client as unknown as { + from: (table: string) => { + update: (payload: Record) => { + eq: ( + column: string, + value: unknown + ) => { + eq: ( + column: string, + value: unknown + ) => { + eq: ( + column: string, + value: unknown + ) => { + is: ( + column: string, + value: null + ) => Promise<{ + data: Record[] | null; + error: unknown; + }>; + }; + }; + }; + }; + }; + }; + + if (args.balloons.length === 0) { + return { data: [], error: null }; + } + + const updated: Record[] = []; + for (const b of args.balloons) { + const payload: Record = { + updatedBy: args.updatedBy, + updatedAt: new Date().toISOString() + }; + if (typeof b.label === "string") payload.label = b.label; + if (typeof b.xCoordinate === "number") payload.xCoordinate = b.xCoordinate; + if (typeof b.yCoordinate === "number") payload.yCoordinate = b.yCoordinate; + if (typeof b.anchorX === "number") payload.anchorX = b.anchorX; + if (typeof b.anchorY === "number") payload.anchorY = b.anchorY; + if (b.data !== undefined) payload.data = b.data; + if (b.description !== undefined) payload.description = b.description; + + const result = await drawingClient + .from("ballooningBalloon") + .update(payload) + .eq("id", b.id) + .eq("drawingId", args.drawingId) + .eq("companyId", args.companyId) + .is("deletedAt", null) + .select("*"); + + if (result.error) { + return { data: null, error: result.error }; + } + if (result.data?.[0]) { + updated.push(result.data[0]); + } + } + + return { data: updated, error: null }; +} + +export async function deleteBallooningBalloons( + client: SupabaseClient, + args: { + drawingId: string; + companyId: string; + updatedBy: string; + ids: string[]; + } +) { + const drawingClient = client as unknown as { + from: (table: string) => { + update: (payload: Record) => { + in: ( + column: string, + values: string[] + ) => { + eq: ( + column: string, + value: unknown + ) => { + eq: ( + column: string, + value: unknown + ) => { + is: ( + column: string, + value: null + ) => Promise<{ + data: Record[] | null; + error: unknown; + }>; + }; + }; + }; + }; + }; + }; + + if (args.ids.length === 0) { + return { data: [], error: null }; + } + + return drawingClient + .from("ballooningBalloon") + .update({ + deletedAt: new Date().toISOString(), + updatedBy: args.updatedBy, + updatedAt: new Date().toISOString() + }) + .in("id", args.ids) + .eq("drawingId", args.drawingId) + .eq("companyId", args.companyId) + .is("deletedAt", null) + .select("id"); } diff --git a/apps/erp/app/modules/quality/ui/Ballooning/BalloonDiagramEditor.tsx b/apps/erp/app/modules/quality/ui/Ballooning/BalloonDiagramEditor.tsx index 665a3593a..1a906aced 100644 --- a/apps/erp/app/modules/quality/ui/Ballooning/BalloonDiagramEditor.tsx +++ b/apps/erp/app/modules/quality/ui/Ballooning/BalloonDiagramEditor.tsx @@ -1,15 +1,9 @@ import { useCarbon } from "@carbon/auth"; import { - Badge, Button, Heading, HStack, - Input, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + IconButton, Table, Tbody, Td, @@ -22,24 +16,30 @@ import { import { useLingui } from "@lingui/react/macro"; import { nanoid } from "nanoid"; import { useCallback, useEffect, useRef, useState } from "react"; +import { Circle, Group, Layer, Line, Rect, Stage, Text } from "react-konva"; import { Document, Page } from "react-pdf"; import "react-pdf/dist/Page/AnnotationLayer.css"; import "react-pdf/dist/Page/TextLayer.css"; +import Papa from "papaparse"; import { + LuChevronDown, + LuChevronUp, + LuDownload, + LuFileDown, + LuFileSpreadsheet, LuLoader, + LuMinus, + LuPlus, LuRectangleHorizontal, LuSave, - LuTrash, + LuTrash2, LuUpload } from "react-icons/lu"; import { useFetcher } from "react-router"; +import * as XLSX from "xlsx"; import { useUser } from "~/hooks"; -import { balloonCharacteristicType } from "~/modules/quality/quality.models"; -import type { - BalloonAnnotation, - BalloonFeature, - BallooningDiagramContent -} from "~/modules/quality/types"; +import type { BallooningDiagramContent } from "~/modules/quality/types"; +import { buildBallooningPdfWithOverlaysBytes } from "./exportBallooningPdfWithOverlays"; type DragState = { startX: number; @@ -47,37 +47,542 @@ type DragState = { currentX: number; currentY: number; } | null; - -type MovingBalloon = { - id: string; - // offset from balloon position (%) to mouse position (%) at drag start - offsetX: number; - offsetY: number; -} | null; +type DragKind = "selector" | "zoom" | null; type BalloonDiagramEditorProps = { diagramId: string; name: string; content: BallooningDiagramContent | null; + selectors: Array>; + balloons: Array>; }; -const BALLOON_RADIUS = 14; - -function generateId() { - return Math.random().toString(36).slice(2, 10); -} +type PdfMetrics = { + pageCount: number; + defaultPageWidth: number; + defaultPageHeight: number; +}; function toPercent(px: number, total: number) { return (px / total) * 100; } +const EDITOR_SPLITTER_H = 8; +const MIN_PDF_PANE_PX = 160; + +/** When the features table is expanded it keeps at least half the editor stack; PDF height is capped accordingly. */ +function clampPdfPaneHeight( + pdfPx: number, + stackH: number, + featuresExpanded: boolean +): number { + if (!featuresExpanded || stackH <= EDITOR_SPLITTER_H + MIN_PDF_PANE_PX) { + return Math.max(MIN_PDF_PANE_PX, pdfPx); + } + const minFeatures = stackH * 0.5; + const maxPdf = Math.max( + MIN_PDF_PANE_PX, + stackH - EDITOR_SPLITTER_H - minFeatures + ); + return Math.min(maxPdf, Math.max(MIN_PDF_PANE_PX, pdfPx)); +} + +/** Callout / selector stroke — matches reference (orange border, hollow fill). */ +const CALLOUT_STROKE = "#f97316"; +const CALLOUT_TEXT = "#171717"; + +/** + * Konva 9 does not apply the `cursor` prop to the DOM; Transformer only sets + * `stage.content.style.cursor` manually. Use these helpers for hover/drag cursors. + */ +function konvaContentFromTarget(target: unknown): HTMLElement | null { + const t = target as { + getStage?: () => { content?: HTMLElement } | null; + } | null; + return t?.getStage?.()?.content ?? null; +} + +function konvaContentFromStageRef(stageRef: { + current: unknown; +}): HTMLElement | null { + const st = stageRef.current as { content?: HTMLElement } | null | undefined; + return st?.content ?? null; +} + +/** Liang–Barsky: clip segment (x0,y0)→(x1,y1) to axis-aligned rect; returns [0,1] params or null. */ +function liangBarskySegmentRect( + x0: number, + y0: number, + x1: number, + y1: number, + minX: number, + minY: number, + maxX: number, + maxY: number +): { u0: number; u1: number } | null { + const dx = x1 - x0; + const dy = y1 - y0; + let u0 = 0; + let u1 = 1; + const p = [-dx, dx, -dy, dy]; + const q = [x0 - minX, maxX - x0, y0 - minY, maxY - y0]; + for (let i = 0; i < 4; i += 1) { + if (Math.abs(p[i]) < 1e-12) { + if (q[i] < 0) return null; + } else { + const r = q[i] / p[i]; + if (p[i] < 0) { + u0 = Math.max(u0, r); + } else { + u1 = Math.min(u1, r); + } + if (u0 > u1) return null; + } + } + return { u0, u1 }; +} + +/** + * Visible connector from balloon edge → toward anchor, stopping before the selector rect interior. + * u is linear param from B (0) to A (1); balloon occupies u ∈ [0, r/L). + */ +function clippedBalloonToAnchorLine( + bx: number, + by: number, + radiusPx: number, + ax: number, + ay: number, + rect: { x: number; y: number; w: number; h: number } +): [number, number, number, number] | null { + const L = Math.hypot(ax - bx, ay - by); + if (L < 1e-6) return null; + const epsU = Math.max(1e-4, 2 / L); + const uBalloonExit = Math.min(1 - epsU, radiusPx / L + epsU); + const { x, y, w, h } = rect; + const hit = liangBarskySegmentRect(bx, by, ax, ay, x, y, x + w, y + h); + let uEnd = 1 - epsU; + if (hit) { + const uEnter = Math.max(0, Math.min(1, hit.u0)); + if (uEnter > uBalloonExit) { + uEnd = Math.min(uEnd, uEnter - epsU); + } + } + if (uEnd <= uBalloonExit + 1e-4) return null; + const x0 = bx + (ax - bx) * uBalloonExit; + const y0 = by + (ay - by) * uBalloonExit; + const x1 = bx + (ax - bx) * uEnd; + const y1 = by + (ay - by) * uEnd; + return [x0, y0, x1, y1]; +} + +type SelectorRect = { + id: string; + pageNumber: number; + x: number; + y: number; + width: number; + height: number; + isNew: boolean; + isDirty: boolean; +}; + +/** One feature row = one balloon + linked selector; table fields mostly from `data` JSONB. */ +type FeatureRow = { + balloonId: string; + selectorId: string; + label: string; + pageNumber: number; + x: number; + y: number; + width: number; + height: number; + anchorX: number; + anchorY: number; + featureName: string; + nominalValue: string; + tolerancePlus: string; + toleranceMinus: string; + units: string; + /** Persisted rows: set when table fields change so Save can PATCH `data`. */ + balloonDirty?: boolean; +}; + +const BALLOON_W_NORM = 0.04; +const BALLOON_H_NORM = 0.04; +const BALLOON_OFFSET_NORM = 0.02; + +function isTempBalloonId(balloonId: string) { + return balloonId.startsWith("temp-bln-"); +} + +function isTempSelectorId(selectorId: string) { + return selectorId.startsWith("temp-"); +} + +function sanitizeFilenameBase(name: string) { + const trimmed = name.trim().replace(/[\\/:*?"<>|]+/g, "_"); + return (trimmed.length > 0 ? trimmed : "diagram").slice(0, 120); +} + +function triggerDownload(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.rel = "noopener"; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + +type NormBalloonRect = { + pageNumber: number; + x: number; + y: number; + width: number; + height: number; +}; + +function clamp01Norm(n: number) { + return Math.max(0, Math.min(1, n)); +} + +function overlapsNorm(a: NormBalloonRect, b: NormBalloonRect) { + if (a.pageNumber !== b.pageNumber) return false; + return !( + a.x + a.width <= b.x || + b.x + b.width <= a.x || + a.y + a.height <= b.y || + b.y + b.height <= a.y + ); +} + +function inBoundsNorm(rect: NormBalloonRect) { + return ( + rect.x >= 0 && + rect.y >= 0 && + rect.x + rect.width <= 1 && + rect.y + rect.height <= 1 + ); +} + +function clampRectToBoundsNorm(rect: NormBalloonRect): NormBalloonRect { + const x = clamp01Norm(Math.min(rect.x, 1 - rect.width)); + const y = clamp01Norm(Math.min(rect.y, 1 - rect.height)); + return { ...rect, x, y }; +} + +const MIN_SELECTOR_DIM_PCT = 1; + +type ResizeHandleId = "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w"; + +function stagePointToPageLocalPercent( + stageX: number, + stageY: number, + pageNumber: number, + renderedWidth: number, + overlayHeight: number, + totalPages: number +): { lx: number; ly: number } { + const tp = Math.max(1, totalPages); + const pageHeightPx = overlayHeight / tp; + const lx = (stageX / renderedWidth) * 100; + const localYpx = stageY - (pageNumber - 1) * pageHeightPx; + const ly = (localYpx / pageHeightPx) * 100; + return { + lx: Math.max(0, Math.min(100, lx)), + ly: Math.max(0, Math.min(100, ly)) + }; +} + +function clampSelectorPagePercentPct(r: { + x: number; + y: number; + width: number; + height: number; +}): { x: number; y: number; width: number; height: number } { + let { x, y, width, height } = r; + width = Math.max(MIN_SELECTOR_DIM_PCT, Math.min(width, 100)); + height = Math.max(MIN_SELECTOR_DIM_PCT, Math.min(height, 100)); + x = Math.max(0, Math.min(x, 100 - width)); + y = Math.max(0, Math.min(y, 100 - height)); + return { x, y, width, height }; +} + +function applySelectorResizeDelta( + handle: ResizeHandleId, + start: { x: number; y: number; width: number; height: number }, + dlx: number, + dly: number +): { x: number; y: number; width: number; height: number } { + const { x, y, width, height } = start; + let nx = x; + let ny = y; + let nw = width; + let nh = height; + switch (handle) { + case "e": + nw = width + dlx; + break; + case "w": + nx = x + dlx; + nw = width - dlx; + break; + case "s": + nh = height + dly; + break; + case "n": + ny = y + dly; + nh = height - dly; + break; + case "se": + nw = width + dlx; + nh = height + dly; + break; + case "sw": + nx = x + dlx; + nw = width - dlx; + nh = height + dly; + break; + case "ne": + ny = y + dly; + nw = width + dlx; + nh = height - dly; + break; + case "nw": + nx = x + dlx; + ny = y + dly; + nw = width - dlx; + nh = height - dly; + break; + default: + break; + } + return clampSelectorPagePercentPct({ x: nx, y: ny, width: nw, height: nh }); +} + +function cursorForResizeHandle(handle: ResizeHandleId): string { + switch (handle) { + case "n": + case "s": + return "ns-resize"; + case "e": + case "w": + return "ew-resize"; + case "nw": + case "se": + return "nwse-resize"; + case "ne": + case "sw": + return "nesw-resize"; + default: + return "pointer"; + } +} + +function featureRowToOccupiedNorm(row: FeatureRow): NormBalloonRect { + return { + pageNumber: row.pageNumber, + x: row.x / 100, + y: row.y / 100, + width: row.width / 100, + height: row.height / 100 + }; +} + +/** Mirrors server `createBalloonsForSelectors` placement candidates. */ +function computeBalloonPlacementFromSelector( + selector: { + pageNumber: number; + x: number; + y: number; + width: number; + height: number; + }, + occupied: NormBalloonRect[] +): NormBalloonRect { + const balloonWidth = BALLOON_W_NORM; + const balloonHeight = BALLOON_H_NORM; + const offset = BALLOON_OFFSET_NORM; + const s = selector; + const candidates: NormBalloonRect[] = [ + { + pageNumber: s.pageNumber, + x: s.x + s.width + offset, + y: s.y, + width: balloonWidth, + height: balloonHeight + }, + { + pageNumber: s.pageNumber, + x: s.x + s.width + offset, + y: s.y - balloonHeight - offset, + width: balloonWidth, + height: balloonHeight + }, + { + pageNumber: s.pageNumber, + x: s.x + s.width + offset, + y: s.y + s.height + offset, + width: balloonWidth, + height: balloonHeight + }, + { + pageNumber: s.pageNumber, + x: s.x, + y: s.y - balloonHeight - offset, + width: balloonWidth, + height: balloonHeight + }, + { + pageNumber: s.pageNumber, + x: s.x, + y: s.y + s.height + offset, + width: balloonWidth, + height: balloonHeight + }, + { + pageNumber: s.pageNumber, + x: s.x - balloonWidth - offset, + y: s.y, + width: balloonWidth, + height: balloonHeight + } + ]; + + const placed = + candidates.find( + (candidate) => + inBoundsNorm(candidate) && + !occupied.some((other) => overlapsNorm(candidate, other)) + ) ?? clampRectToBoundsNorm(candidates[0]!); + + return placed; +} + +function nextBalloonLabel(rows: FeatureRow[]): string { + const nums = rows + .map((r) => parseInt(r.label, 10)) + .filter((n) => Number.isFinite(n)); + const max = nums.length ? Math.max(...nums) : 0; + return String(max + 1); +} + +function buildBalloonDataForSave( + row: FeatureRow, + sel: SelectorRect | undefined +): Record { + const out: Record = { + source: "client-save", + pageNumber: row.pageNumber, + placement: { + width: row.width / 100, + height: row.height / 100, + offset: BALLOON_OFFSET_NORM + }, + featureName: row.featureName + }; + if (sel) { + out.selector = { + x: sel.x / 100, + y: sel.y / 100, + width: sel.width / 100, + height: sel.height / 100 + }; + } + if (row.nominalValue.trim()) out.nominalValue = row.nominalValue.trim(); + if (row.tolerancePlus.trim()) out.tolerancePlus = row.tolerancePlus.trim(); + if (row.toleranceMinus.trim()) out.toleranceMinus = row.toleranceMinus.trim(); + if (row.units.trim()) out.units = row.units.trim(); + return out; +} + +function mapSelectorRecord(s: Record): SelectorRect { + return { + id: String(s.id), + pageNumber: Number(s.pageNumber ?? 1), + x: Number(s.xCoordinate ?? 0) * 100, + y: Number(s.yCoordinate ?? 0) * 100, + width: Number(s.width ?? 0) * 100, + height: Number(s.height ?? 0) * 100, + isNew: false, + isDirty: false + }; +} + +function strFromData(data: Record, key: string) { + const v = data[key]; + if (v === null || v === undefined) return ""; + return String(v); +} + +function mapFeatureRowFromBalloon(b: Record): FeatureRow { + const data = ( + typeof b.data === "object" && b.data !== null + ? (b.data as Record) + : {} + ) as Record; + + const desc = + b.description != null && String(b.description).trim() !== "" + ? String(b.description) + : ""; + const featureName = + strFromData(data, "featureName") || + strFromData(data, "feature") || + desc || + `Feature ${String(b.label ?? "")}`; + + const raw = b as Record; + const selectorIdRaw = raw.selectorId ?? raw.selector_id; + + return { + balloonId: String(b.id), + selectorId: + typeof selectorIdRaw === "string" + ? selectorIdRaw + : selectorIdRaw != null + ? String(selectorIdRaw) + : "", + label: String(b.label ?? ""), + pageNumber: Number(data.pageNumber ?? 1), + x: Number(b.xCoordinate ?? 0) * 100, + y: Number(b.yCoordinate ?? 0) * 100, + width: + Number( + (data.placement as { width?: number } | undefined)?.width ?? 0.04 + ) * 100, + height: + Number( + (data.placement as { height?: number } | undefined)?.height ?? 0.04 + ) * 100, + anchorX: Number(b.anchorX ?? 0) * 100, + anchorY: Number(b.anchorY ?? 0) * 100, + featureName, + nominalValue: strFromData(data, "nominalValue"), + tolerancePlus: strFromData(data, "tolerancePlus"), + toleranceMinus: strFromData(data, "toleranceMinus"), + units: strFromData(data, "units"), + balloonDirty: false + }; +} + export default function BalloonDiagramEditor({ diagramId, name, - content + content, + selectors, + balloons }: BalloonDiagramEditorProps) { const { t } = useLingui(); - const fetcher = useFetcher<{ success: boolean; message?: string }>(); + const fetcher = useFetcher<{ + success: boolean; + message?: string; + selectorIdMap?: Record; + selectors?: Array>; + balloons?: Array>; + }>(); const { carbon } = useCarbon(); const user = useUser(); const companyId = user.company.id; @@ -85,27 +590,161 @@ export default function BalloonDiagramEditor({ const [pdfUrl, setPdfUrl] = useState(content?.pdfUrl ?? ""); const [pdfFile, setPdfFile] = useState(null); const [uploading, setUploading] = useState(false); - const [annotations, setAnnotations] = useState( - content?.annotations ?? [] + const [selectorRects, setSelectorRects] = useState( + selectors.map(mapSelectorRecord) ); - const [features, setFeatures] = useState( - content?.features ?? [] + const [featureRows, setFeatureRows] = useState(() => + balloons.map(mapFeatureRowFromBalloon) ); const [placing, setPlacing] = useState(false); - const [selectedBalloon, setSelectedBalloon] = useState(null); + const [zoomBoxMode, setZoomBoxMode] = useState(false); + const [zoomScale, setZoomScale] = useState(1); const [numPages, setNumPages] = useState(0); + const [pdfMetrics, setPdfMetrics] = useState(null); const [isMounted, setIsMounted] = useState(false); const [drag, setDrag] = useState(null); - const [movingBalloon, setMovingBalloon] = useState(null); + const [dragKind, setDragKind] = useState(null); const [containerWidth, setContainerWidth] = useState(0); + const [overlayHeight, setOverlayHeight] = useState(0); + /** Expanded: full table. Collapsed: header + one data row (more room for PDF). */ + const [featuresTableExpanded, setFeaturesTableExpanded] = useState(true); + /** Height of PDF block when table is expanded (px); drag the splitter to adjust. */ + const [pdfPaneHeightPx, setPdfPaneHeightPx] = useState(360); + const [editorStackHeightPx, setEditorStackHeightPx] = useState(0); + const [isResizingPdfFeatures, setIsResizingPdfFeatures] = useState(false); + const [pdfExporting, setPdfExporting] = useState(false); const overlayRef = useRef(null); const containerRef = useRef(null); + const stageRef = useRef(null); + const editorStackRef = useRef(null); + const splitDragRef = useRef<{ startY: number; startPdfPx: number } | null>( + null + ); const fileInputRef = useRef(null); + /** Only the explicit Save button should show "Diagram saved" — not auto-persist after selector draw. */ + const manualSaveToastRef = useRef(false); + /** Persisted ids to soft-delete on next Save (cleared after successful reload). */ + const pendingBalloonDeleteIdsRef = useRef(new Set()); + const pendingSelectorDeleteIdsRef = useRef(new Set()); + + const [selectedBalloonId, setSelectedBalloonId] = useState( + null + ); + const [selectedSelectorId, setSelectedSelectorId] = useState( + null + ); + + type BalloonDragSession = { + balloonId: string; + startPointer: { x: number; y: number }; + startRow: { x: number; y: number; width: number; height: number }; + renderedWidth: number; + pageHeightPx: number; + }; + + type SelectorResizeSession = { + selectorId: string; + handle: ResizeHandleId; + startRect: { x: number; y: number; width: number; height: number }; + pageNumber: number; + startPointerLocal: { lx: number; ly: number }; + renderedWidth: number; + overlayHeight: number; + totalPages: number; + }; + + const balloonDragSessionRef = useRef(null); + const selectorResizeSessionRef = useRef(null); + const onBalloonDragMoveRef = useRef<(ev: MouseEvent) => void>(() => {}); + const onBalloonDragUpRef = useRef<(ev: MouseEvent) => void>(() => {}); + const onSelectorResizeMoveRef = useRef<(ev: MouseEvent) => void>(() => {}); + const onSelectorResizeUpRef = useRef<(ev: MouseEvent) => void>(() => {}); + + const finalizeBalloonDrag = useCallback(() => { + const session = balloonDragSessionRef.current; + window.removeEventListener("mousemove", onBalloonDragMoveRef.current); + window.removeEventListener("mouseup", onBalloonDragUpRef.current); + balloonDragSessionRef.current = null; + const stageContent = konvaContentFromStageRef(stageRef); + if (stageContent) stageContent.style.cursor = ""; + if (!session) return; + setFeatureRows((prev) => + prev.map((r) => { + if (r.balloonId !== session.balloonId) return r; + if (isTempBalloonId(r.balloonId)) return r; + const moved = + Math.abs(r.x - session.startRow.x) > 0.05 || + Math.abs(r.y - session.startRow.y) > 0.05; + return moved ? { ...r, balloonDirty: true } : r; + }) + ); + }, []); + + const finalizeSelectorResize = useCallback(() => { + window.removeEventListener("mousemove", onSelectorResizeMoveRef.current); + window.removeEventListener("mouseup", onSelectorResizeUpRef.current); + selectorResizeSessionRef.current = null; + const stageContent = konvaContentFromStageRef(stageRef); + if (stageContent) stageContent.style.cursor = ""; + }, []); useEffect(() => { setIsMounted(true); }, []); + useEffect(() => { + if (!editorStackRef.current) return; + const el = editorStackRef.current; + const ro = new ResizeObserver(() => { + const h = el.clientHeight; + setEditorStackHeightPx(h); + setPdfPaneHeightPx((prev) => + clampPdfPaneHeight(prev, h, featuresTableExpanded) + ); + }); + ro.observe(el); + return () => ro.disconnect(); + }, [featuresTableExpanded]); + + useEffect(() => { + if (!isResizingPdfFeatures) return; + const onMove = (e: MouseEvent) => { + const start = splitDragRef.current; + if (!start) return; + const dy = e.clientY - start.startY; + setPdfPaneHeightPx( + clampPdfPaneHeight( + start.startPdfPx + dy, + editorStackHeightPx, + featuresTableExpanded + ) + ); + }; + const onUp = () => { + setIsResizingPdfFeatures(false); + splitDragRef.current = null; + }; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + return () => { + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + }, [isResizingPdfFeatures, editorStackHeightPx, featuresTableExpanded]); + + const onSplitResizeMouseDown = useCallback( + (e: React.MouseEvent) => { + if (!featuresTableExpanded) return; + e.preventDefault(); + splitDragRef.current = { + startY: e.clientY, + startPdfPx: pdfPaneHeightPx + }; + setIsResizingPdfFeatures(true); + }, + [featuresTableExpanded, pdfPaneHeightPx] + ); + // Measure container width and keep it up to date on resize useEffect(() => { if (!containerRef.current) return; @@ -117,80 +756,237 @@ export default function BalloonDiagramEditor({ return () => ro.disconnect(); }, []); + useEffect(() => { + if (!overlayRef.current) return; + const ro = new ResizeObserver((entries) => { + const h = entries[0]?.contentRect.height ?? 0; + setOverlayHeight(h); + }); + ro.observe(overlayRef.current); + return () => ro.disconnect(); + }, [numPages, containerWidth, pdfUrl, pdfFile]); + useEffect(() => { if (fetcher.data?.success === true) { - toast.success(t`Diagram saved`); + setSelectorRects((fetcher.data.selectors ?? []).map(mapSelectorRecord)); + setFeatureRows( + (fetcher.data.balloons ?? []).map(mapFeatureRowFromBalloon) + ); + pendingBalloonDeleteIdsRef.current.clear(); + pendingSelectorDeleteIdsRef.current.clear(); + if (manualSaveToastRef.current) { + toast.success(t`Diagram saved`); + manualSaveToastRef.current = false; + } } else if (fetcher.data?.success === false) { + manualSaveToastRef.current = false; toast.error(fetcher.data.message ?? t`Failed to save diagram`); } }, [fetcher.data, t]); - const nextBalloonNumber = useCallback(() => { - if (annotations.length === 0) return 1; - return Math.max(...annotations.map((a) => a.balloonNumber)) + 1; - }, [annotations]); - - const getRelativePos = useCallback((e: React.MouseEvent) => { - if (!overlayRef.current) return { x: 0, y: 0 }; - const rect = overlayRef.current.getBoundingClientRect(); - return { - x: toPercent(e.clientX - rect.left, rect.width), - y: toPercent(e.clientY - rect.top, rect.height) - }; + const getRelativePosFromStage = useCallback(() => { + const stage = stageRef.current as { + getPointerPosition: () => { x: number; y: number } | null; + width: () => number; + height: () => number; + } | null; + const pos = stage?.getPointerPosition?.() ?? null; + if (!pos || !stage) return { x: 0, y: 0 }; + const w = stage.width(); + const h = stage.height(); + return { x: toPercent(pos.x, w), y: toPercent(pos.y, h) }; }, []); - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - if (!placing || movingBalloon) return; - e.preventDefault(); - const { x, y } = getRelativePos(e); - setDrag({ startX: x, startY: y, currentX: x, currentY: y }); - }, - [placing, movingBalloon, getRelativePos] - ); + const getStagePointerPx = useCallback(() => { + const stage = stageRef.current as { + getPointerPosition: () => { x: number; y: number } | null; + } | null; + return stage?.getPointerPosition?.() ?? null; + }, []); - const handleMouseMove = useCallback( - (e: React.MouseEvent) => { - if (movingBalloon) { - const { x, y } = getRelativePos(e); - const newX = x - movingBalloon.offsetX; - const newY = y - movingBalloon.offsetY; - setAnnotations((prev) => - prev.map((a) => { - if (a.id !== movingBalloon.id) return a; - const dx = newX - a.x; - const dy = newY - a.y; - return { - ...a, - x: newX, - y: newY, - rect: a.rect - ? { - ...a.rect, - x: a.rect.x + dx, - y: a.rect.y + dy - } - : a.rect - }; - }) - ); + const beginBalloonPointerDrag = useCallback( + ( + balloonId: string, + rowSnapshot: { x: number; y: number; width: number; height: number }, + renderedWidth: number, + overlayHeight: number, + totalPages: number + ) => { + if ( + balloonDragSessionRef.current || + selectorResizeSessionRef.current || + !renderedWidth || + !overlayHeight + ) { return; } - if (!drag) return; - const { x, y } = getRelativePos(e); - setDrag((d) => (d ? { ...d, currentX: x, currentY: y } : null)); + const pos = getStagePointerPx(); + if (!pos) return; + const tp = Math.max(1, totalPages); + const pageHeightPx = overlayHeight / tp; + balloonDragSessionRef.current = { + balloonId, + startPointer: { x: pos.x, y: pos.y }, + startRow: { ...rowSnapshot }, + renderedWidth, + pageHeightPx + }; + const onMove = () => { + const session = balloonDragSessionRef.current; + if (!session) return; + const p = getStagePointerPx(); + if (!p) return; + const dx = + ((p.x - session.startPointer.x) / session.renderedWidth) * 100; + const dy = + ((p.y - session.startPointer.y) / session.pageHeightPx) * 100; + const nx = Math.max( + 0, + Math.min(100 - session.startRow.width, session.startRow.x + dx) + ); + const ny = Math.max( + 0, + Math.min(100 - session.startRow.height, session.startRow.y + dy) + ); + setFeatureRows((prev) => + prev.map((r) => + r.balloonId === session.balloonId ? { ...r, x: nx, y: ny } : r + ) + ); + }; + const onUp = () => { + finalizeBalloonDrag(); + }; + onBalloonDragMoveRef.current = onMove; + onBalloonDragUpRef.current = onUp; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + const stageContent = konvaContentFromStageRef(stageRef); + if (stageContent) stageContent.style.cursor = "grabbing"; }, - [drag, movingBalloon, getRelativePos] + [getStagePointerPx, finalizeBalloonDrag] ); - const handleMouseUp = useCallback( - (e: React.MouseEvent) => { - if (movingBalloon) { - setMovingBalloon(null); + const beginSelectorResize = useCallback( + ( + selectorId: string, + handle: ResizeHandleId, + startRect: { x: number; y: number; width: number; height: number }, + pageNumber: number, + renderedWidth: number, + overlayHeight: number, + totalPages: number + ) => { + if ( + balloonDragSessionRef.current || + selectorResizeSessionRef.current || + !renderedWidth || + !overlayHeight + ) { return; } - if (!drag || !placing) return; - const { x, y } = getRelativePos(e); + const pos = getStagePointerPx(); + if (!pos) return; + const local = stagePointToPageLocalPercent( + pos.x, + pos.y, + pageNumber, + renderedWidth, + overlayHeight, + totalPages + ); + selectorResizeSessionRef.current = { + selectorId, + handle, + startRect: { ...startRect }, + pageNumber, + startPointerLocal: { lx: local.lx, ly: local.ly }, + renderedWidth, + overlayHeight, + totalPages + }; + const onMove = () => { + const session = selectorResizeSessionRef.current; + if (!session) return; + const p = getStagePointerPx(); + if (!p) return; + const cur = stagePointToPageLocalPercent( + p.x, + p.y, + session.pageNumber, + session.renderedWidth, + session.overlayHeight, + session.totalPages + ); + const dlx = cur.lx - session.startPointerLocal.lx; + const dly = cur.ly - session.startPointerLocal.ly; + const next = applySelectorResizeDelta( + session.handle, + session.startRect, + dlx, + dly + ); + setSelectorRects((prev) => + prev.map((s) => + s.id === session.selectorId + ? { + ...s, + x: next.x, + y: next.y, + width: next.width, + height: next.height, + isDirty: !s.isNew ? true : s.isDirty + } + : s + ) + ); + const anchorX = next.x + next.width / 2; + const anchorY = next.y + next.height / 2; + setFeatureRows((prev) => + prev.map((r) => + r.selectorId !== session.selectorId + ? r + : { + ...r, + anchorX, + anchorY, + balloonDirty: isTempBalloonId(r.balloonId) + ? r.balloonDirty + : true + } + ) + ); + }; + const onUp = () => { + finalizeSelectorResize(); + }; + onSelectorResizeMoveRef.current = onMove; + onSelectorResizeUpRef.current = onUp; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + const stageContent = konvaContentFromStageRef(stageRef); + if (stageContent) { + stageContent.style.cursor = cursorForResizeHandle(handle); + } + }, + [getStagePointerPx, finalizeSelectorResize] + ); + + useEffect( + () => () => { + window.removeEventListener("mousemove", onBalloonDragMoveRef.current); + window.removeEventListener("mouseup", onBalloonDragUpRef.current); + window.removeEventListener("mousemove", onSelectorResizeMoveRef.current); + window.removeEventListener("mouseup", onSelectorResizeUpRef.current); + balloonDragSessionRef.current = null; + selectorResizeSessionRef.current = null; + }, + [] + ); + + const finalizeDragAt = useCallback( + (x: number, y: number) => { + if (!drag || !dragKind) return; const rx = Math.min(drag.startX, x); const ry = Math.min(drag.startY, y); @@ -198,70 +994,278 @@ export default function BalloonDiagramEditor({ const rh = Math.abs(y - drag.startY); if (rw < 0.5 || rh < 0.5) { + setDragKind(null); setDrag(null); return; } - const num = nextBalloonNumber(); - const id = generateId(); - - setAnnotations((prev) => [ - ...prev, - { - id, - balloonNumber: num, - x: rx + rw, // balloon pin at top-right of rect - y: ry, - page: 1, - rect: { x: rx, y: ry, width: rw, height: rh } + if (dragKind === "zoom") { + if (!containerRef.current || !overlayRef.current) { + setDragKind(null); + setDrag(null); + return; } - ]); - setFeatures((prev) => [ + const overlayRect = overlayRef.current.getBoundingClientRect(); + const boxWidthPx = (rw / 100) * overlayRect.width; + const boxHeightPx = (rh / 100) * overlayRect.height; + if (boxWidthPx < 8 || boxHeightPx < 8) { + setDragKind(null); + setDrag(null); + return; + } + const fitX = containerRef.current.clientWidth / boxWidthPx; + const fitY = containerRef.current.clientHeight / boxHeightPx; + const nextZoom = Math.max( + 0.5, + Math.min(3, Number((zoomScale * Math.min(fitX, fitY)).toFixed(2))) + ); + const zoomRatio = nextZoom / zoomScale; + const centerXPx = ((rx + rw / 2) / 100) * overlayRect.width; + const centerYPx = ((ry + rh / 2) / 100) * overlayHeight; + setZoomScale(nextZoom); + requestAnimationFrame(() => { + if (!containerRef.current) return; + containerRef.current.scrollLeft = + centerXPx * zoomRatio - containerRef.current.clientWidth / 2; + containerRef.current.scrollTop = + centerYPx * zoomRatio - containerRef.current.clientHeight / 2; + }); + setDragKind(null); + setDrag(null); + return; + } + + const totalPages = Math.max(1, pdfMetrics?.pageCount ?? numPages ?? 1); + const pageHeightPct = 100 / totalPages; + const pageNumber = Math.min( + totalPages, + Math.max(1, Math.floor(ry / pageHeightPct) + 1) + ); + const pageStartPct = (pageNumber - 1) * pageHeightPct; + const localY = ((ry - pageStartPct) / pageHeightPct) * 100; + const localHeight = (rh / pageHeightPct) * 100; + const clippedLocalHeight = Math.min(localHeight, 100 - localY); + + if (clippedLocalHeight < 0.5) { + setDragKind(null); + setDrag(null); + return; + } + + const tempId = `temp-${nanoid()}`; + + const normSelector = { + pageNumber, + x: rx / 100, + y: localY / 100, + width: rw / 100, + height: clippedLocalHeight / 100 + }; + const anchorXNorm = clamp01Norm(normSelector.x + normSelector.width / 2); + const anchorYNorm = clamp01Norm(normSelector.y + normSelector.height / 2); + + setFeatureRows((prev) => { + const occupied = prev.map(featureRowToOccupiedNorm); + const placed = computeBalloonPlacementFromSelector( + normSelector, + occupied + ); + const label = nextBalloonLabel(prev); + const tempBalloonId = `temp-bln-${nanoid()}`; + const row: FeatureRow = { + balloonId: tempBalloonId, + selectorId: tempId, + label, + pageNumber, + x: placed.x * 100, + y: placed.y * 100, + width: BALLOON_W_NORM * 100, + height: BALLOON_H_NORM * 100, + anchorX: anchorXNorm * 100, + anchorY: anchorYNorm * 100, + featureName: `Feature ${label}`, + nominalValue: "", + tolerancePlus: "", + toleranceMinus: "", + units: "" + }; + return [...prev, row]; + }); + + setSelectorRects((prev) => [ ...prev, { - id, - balloonNumber: num, - description: "", - nominalValue: null, - tolerancePlus: null, - toleranceMinus: null, - unitOfMeasureCode: null, - characteristicType: null, - sortOrder: num + id: tempId, + pageNumber, + x: rx, + y: localY, + width: rw, + height: clippedLocalHeight, + isNew: true, + isDirty: false } ]); + setDragKind(null); setDrag(null); - setPlacing(false); }, - [drag, placing, movingBalloon, getRelativePos, nextBalloonNumber] + [drag, pdfMetrics, numPages, dragKind, zoomScale, overlayHeight] ); - const removeAnnotation = useCallback((id: string) => { - setAnnotations((prev) => prev.filter((a) => a.id !== id)); - setFeatures((prev) => prev.filter((f) => f.id !== id)); - setSelectedBalloon(null); - }, []); + const handleStageMouseDown = useCallback( + (e: unknown) => { + const ke = e as { + evt?: MouseEvent; + target?: unknown; + cancelBubble?: boolean; + getTarget?: () => unknown; + }; + const evt = ke.evt; + if (!evt) return; - const updateFeature = useCallback( - (id: string, field: keyof BalloonFeature, value: unknown) => { - setFeatures((prev) => - prev.map((f) => (f.id === id ? { ...f, [field]: value } : f)) - ); + if (!placing && !zoomBoxMode) { + const target = ke.target; + if (target && target === stageRef.current) { + setSelectedBalloonId(null); + setSelectedSelectorId(null); + } + } + + if (placing) { + evt.preventDefault(); + const { x, y } = getRelativePosFromStage(); + setDragKind("selector"); + setDrag({ startX: x, startY: y, currentX: x, currentY: y }); + return; + } + if (zoomBoxMode) { + evt.preventDefault(); + const { x, y } = getRelativePosFromStage(); + setDragKind("zoom"); + setDrag({ startX: x, startY: y, currentX: x, currentY: y }); + return; + } }, - [] + [placing, zoomBoxMode, getRelativePosFromStage] + ); + + const handleStageMouseMove = useCallback( + (e: unknown) => { + const evt = (e as { evt?: MouseEvent }).evt; + if (!evt) return; + + if (!drag) return; + const { x, y } = getRelativePosFromStage(); + setDrag((d) => (d ? { ...d, currentX: x, currentY: y } : null)); + }, + [drag, getRelativePosFromStage] + ); + + const handleStageMouseUp = useCallback( + (e: unknown) => { + const evt = (e as { evt?: MouseEvent }).evt; + if (!evt) return; + + if (!drag || !dragKind) return; + + const { x, y } = getRelativePosFromStage(); + finalizeDragAt(x, y); + }, + [drag, dragKind, getRelativePosFromStage, finalizeDragAt] ); const handleSave = useCallback(() => { + manualSaveToastRef.current = true; const formData = new FormData(); formData.set("name", name); - formData.set("annotations", JSON.stringify(annotations)); - formData.set("features", JSON.stringify(features)); if (pdfUrl) formData.set("pdfUrl", pdfUrl); + const createSelectors = selectorRects + .filter((s) => s.isNew) + .map((s) => ({ + tempId: s.id, + pageNumber: s.pageNumber, + xCoordinate: s.x / 100, + yCoordinate: s.y / 100, + width: s.width / 100, + height: s.height / 100 + })); + const updateSelectors = selectorRects + .filter((s) => !s.isNew && s.isDirty) + .map((s) => ({ + id: s.id, + pageNumber: s.pageNumber, + xCoordinate: s.x / 100, + yCoordinate: s.y / 100, + width: s.width / 100, + height: s.height / 100 + })); + formData.set( + "selectors", + JSON.stringify({ + create: createSelectors, + update: updateSelectors, + delete: [...pendingSelectorDeleteIdsRef.current] + }) + ); + + const balloonsCreate = featureRows + .filter((r) => isTempBalloonId(r.balloonId)) + .map((r) => { + const sel = selectorRects.find((s) => s.id === r.selectorId); + return { + tempSelectorId: r.selectorId, + label: r.label, + xCoordinate: r.x / 100, + yCoordinate: r.y / 100, + anchorX: r.anchorX / 100, + anchorY: r.anchorY / 100, + data: buildBalloonDataForSave(r, sel), + description: r.featureName.trim() || null + }; + }); + + const balloonsUpdate = featureRows + .filter((r) => !isTempBalloonId(r.balloonId) && r.balloonDirty) + .map((r) => { + const sel = selectorRects.find((s) => s.id === r.selectorId); + return { + id: r.balloonId, + label: r.label, + xCoordinate: r.x / 100, + yCoordinate: r.y / 100, + anchorX: r.anchorX / 100, + anchorY: r.anchorY / 100, + data: buildBalloonDataForSave(r, sel), + description: r.featureName.trim() || null + }; + }); + + formData.set( + "balloons", + JSON.stringify({ + create: balloonsCreate, + update: balloonsUpdate, + delete: [...pendingBalloonDeleteIdsRef.current] + }) + ); + + if (pdfMetrics) { + formData.set("pageCount", String(pdfMetrics.pageCount)); + formData.set("defaultPageWidth", String(pdfMetrics.defaultPageWidth)); + formData.set("defaultPageHeight", String(pdfMetrics.defaultPageHeight)); + } fetcher.submit(formData, { method: "post", action: `/x/ballooning-diagram/${diagramId}/save` }); - }, [diagramId, name, annotations, features, pdfUrl, fetcher]); + }, [ + diagramId, + name, + pdfUrl, + selectorRects, + featureRows, + pdfMetrics, + fetcher + ]); const handlePdfUpload = useCallback( async (e: React.ChangeEvent) => { @@ -292,6 +1296,163 @@ export default function BalloonDiagramEditor({ const hasPdf = pdfFile !== null || pdfUrl !== ""; + const handleDeleteFeature = useCallback((balloonId: string) => { + let selectorIdToRemove: string | undefined; + setFeatureRows((prev) => { + const row = prev.find((r) => r.balloonId === balloonId); + selectorIdToRemove = row?.selectorId; + if (row) { + if (!isTempBalloonId(row.balloonId)) { + pendingBalloonDeleteIdsRef.current.add(row.balloonId); + } + if (row.selectorId && !isTempSelectorId(row.selectorId)) { + pendingSelectorDeleteIdsRef.current.add(row.selectorId); + } + } + const nextRows = prev.filter((r) => r.balloonId !== balloonId); + const keptSelectorIds = new Set( + nextRows + .map((r) => r.selectorId) + .filter((id): id is string => id.length > 0) + ); + setSelectorRects((sels) => { + for (const s of sels) { + if (!keptSelectorIds.has(s.id) && !isTempSelectorId(s.id)) { + pendingSelectorDeleteIdsRef.current.add(s.id); + } + } + return sels.filter((sel) => keptSelectorIds.has(sel.id)); + }); + return nextRows; + }); + setSelectedBalloonId((cur) => (cur === balloonId ? null : cur)); + setSelectedSelectorId((cur) => + selectorIdToRemove && cur === selectorIdToRemove ? null : cur + ); + }, []); + + const updateFeatureField = useCallback( + ( + balloonId: string, + field: + | "label" + | "featureName" + | "nominalValue" + | "tolerancePlus" + | "toleranceMinus" + | "units", + value: string + ) => { + setFeatureRows((prev) => + prev.map((r) => + r.balloonId !== balloonId + ? r + : { + ...r, + [field]: value, + balloonDirty: isTempBalloonId(r.balloonId) + ? r.balloonDirty + : true + } + ) + ); + }, + [] + ); + + const handleExportFeaturesCsv = useCallback(() => { + if (featureRows.length === 0) { + toast.error(t`Nothing to export`); + return; + } + const cols = [ + t`Balloon #`, + t`Feature`, + t`Nom`, + t`Tol+`, + t`Tol-`, + t`Units` + ] as const; + const objects = featureRows.map((r) => ({ + [cols[0]]: r.label, + [cols[1]]: r.featureName, + [cols[2]]: r.nominalValue, + [cols[3]]: r.tolerancePlus, + [cols[4]]: r.toleranceMinus, + [cols[5]]: r.units + })); + const csv = Papa.unparse(objects, { columns: [...cols] }); + const blob = new Blob([`\uFEFF${csv}`], { + type: "text/csv;charset=utf-8;" + }); + triggerDownload(blob, `${sanitizeFilenameBase(name)}-features.csv`); + }, [featureRows, name, t]); + + const handleExportFeaturesXlsx = useCallback(() => { + if (featureRows.length === 0) { + toast.error(t`Nothing to export`); + return; + } + const cols = [ + t`Balloon #`, + t`Feature`, + t`Nom`, + t`Tol+`, + t`Tol-`, + t`Units` + ] as const; + const aoa: string[][] = [ + [...cols], + ...featureRows.map((r) => [ + r.label, + r.featureName, + r.nominalValue, + r.tolerancePlus, + r.toleranceMinus, + r.units + ]) + ]; + const ws = XLSX.utils.aoa_to_sheet(aoa); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "Features"); + XLSX.writeFile(wb, `${sanitizeFilenameBase(name)}-features.xlsx`); + }, [featureRows, name, t]); + + const handleDownloadPdfWithBalloons = useCallback(async () => { + if (!hasPdf) { + toast.error(t`Upload a PDF first`); + return; + } + setPdfExporting(true); + try { + let bytes: ArrayBuffer; + if (pdfFile) { + bytes = await pdfFile.arrayBuffer(); + } else { + const res = await fetch(pdfUrl, { credentials: "include" }); + if (!res.ok) { + throw new Error(String(res.status)); + } + bytes = await res.arrayBuffer(); + } + const outBytes = await buildBallooningPdfWithOverlaysBytes({ + pdfBytes: bytes, + featureRows, + selectorRects, + scale: 2 + }); + triggerDownload( + new Blob([outBytes], { type: "application/pdf" }), + `${sanitizeFilenameBase(name)}-with-balloons.pdf` + ); + toast.success(t`PDF downloaded`); + } catch { + toast.error(t`Could not build PDF. Try again.`); + } finally { + setPdfExporting(false); + } + }, [hasPdf, pdfFile, pdfUrl, name, featureRows, selectorRects, t]); + const previewRect = drag ? { x: Math.min(drag.startX, drag.currentX), @@ -300,10 +1461,15 @@ export default function BalloonDiagramEditor({ height: Math.abs(drag.currentY - drag.startY) } : null; - - const sortedFeatures = [...features].sort( - (a, b) => a.balloonNumber - b.balloonNumber - ); + const renderedWidth = + containerWidth > 0 ? Math.max(1, containerWidth * zoomScale) : 0; + const totalPagesStage = Math.max(1, pdfMetrics?.pageCount ?? numPages ?? 1); + const pdfOverlayInteract = + hasPdf && + !placing && + !zoomBoxMode && + renderedWidth > 0 && + overlayHeight > 0; return (
@@ -317,14 +1483,14 @@ export default function BalloonDiagramEditor({ disabled={uploading} /> - {/* Header bar */} -
- + {/* Header bar — min-height only so controls are not clipped when the row wraps */} +
+ - - {name} + + {name} {content?.drawingNumber && ( - + {content.drawingNumber} {content.revision ? ` Rev ${content.revision}` : ""} @@ -332,14 +1498,45 @@ export default function BalloonDiagramEditor({ - + + {hasPdf && ( + )} + + } + onClick={() => + setZoomScale((z) => Math.max(0.5, Number((z - 0.1).toFixed(2)))) + } + /> + + {Math.round(zoomScale * 100)}% + + } + onClick={() => + setZoomScale((z) => Math.min(3, Number((z + 0.1).toFixed(2)))) + } + /> + +
-
- {/* PDF viewer — outer measures width, inner fills container */} +
- {hasPdf ? ( + {/* PDF viewer — outer measures width, inner fills container */} +
+ {hasPdf ? ( +
0 ? renderedWidth : "100%" }} + onMouseLeave={() => { + if (drag) setDrag(null); + if (dragKind) setDragKind(null); + finalizeBalloonDrag(); + finalizeSelectorResize(); + const el = konvaContentFromStageRef(stageRef); + if (el) el.style.cursor = ""; + }} + > + {isMounted && ( +
+ { + setNumPages(pdf.numPages); + try { + const page = await pdf.getPage(1); + const viewport = page.getViewport({ scale: 1 }); + setPdfMetrics({ + pageCount: pdf.numPages, + defaultPageWidth: viewport.width, + defaultPageHeight: viewport.height + }); + } catch { + setPdfMetrics(null); + } + }} + onLoadError={(err) => + toast.error(`PDF error: ${err.message}`) + } + > + {Array.from({ length: numPages }, (_, i) => ( + 0 ? renderedWidth : undefined} + renderTextLayer={false} + renderAnnotationLayer={false} + className="w-full" + /> + ))} + +
+ )} + + {containerWidth > 0 && overlayHeight > 0 && ( +
+ + + {selectorRects.map((s) => { + const pageHeightPx = overlayHeight / totalPagesStage; + const x = (s.x / 100) * renderedWidth; + const y = + (s.pageNumber - 1) * pageHeightPx + + (s.y / 100) * pageHeightPx; + const width = (s.width / 100) * renderedWidth; + const height = (s.height / 100) * pageHeightPx; + const isSel = selectedSelectorId === s.id; + + return ( + { + if (!pdfOverlayInteract) return; + const el = konvaContentFromTarget(e.target); + if (el) el.style.cursor = "pointer"; + }} + onMouseLeave={(e) => { + const el = konvaContentFromTarget(e.target); + if (el) el.style.cursor = ""; + }} + onMouseDown={(e) => { + if (!pdfOverlayInteract) return; + e.cancelBubble = true; + setSelectedSelectorId(s.id); + const linked = featureRows.find( + (r) => r.selectorId === s.id + ); + setSelectedBalloonId(linked?.balloonId ?? null); + }} + /> + ); + })} + {featureRows.map((b) => { + const pageHeightPx = overlayHeight / totalPagesStage; + const pageOffsetY = (b.pageNumber - 1) * pageHeightPx; + const balloonWidthPx = + (b.width / 100) * renderedWidth; + const balloonHeightPx = + (b.height / 100) * pageHeightPx; + const balloonX = (b.x / 100) * renderedWidth; + const balloonY = + pageOffsetY + (b.y / 100) * pageHeightPx; + const balloonCenterX = balloonX + balloonWidthPx / 2; + const balloonCenterY = balloonY + balloonHeightPx / 2; + const anchorX = (b.anchorX / 100) * renderedWidth; + const anchorY = + pageOffsetY + (b.anchorY / 100) * pageHeightPx; + const radius = Math.max( + 8, + Math.min(balloonWidthPx, balloonHeightPx) / 2 + ); + const balloonSelected = + selectedBalloonId === b.balloonId; + + const linkedSelector = selectorRects.find( + (s) => s.id === b.selectorId + ); + let linePoints: + | [number, number, number, number] + | null = null; + if (linkedSelector) { + const sx = (linkedSelector.x / 100) * renderedWidth; + const sy = + (linkedSelector.pageNumber - 1) * pageHeightPx + + (linkedSelector.y / 100) * pageHeightPx; + const sw = + (linkedSelector.width / 100) * renderedWidth; + const sh = + (linkedSelector.height / 100) * pageHeightPx; + linePoints = clippedBalloonToAnchorLine( + balloonCenterX, + balloonCenterY, + radius, + anchorX, + anchorY, + { x: sx, y: sy, w: sw, h: sh } + ); + } else { + const L = Math.hypot( + anchorX - balloonCenterX, + anchorY - balloonCenterY + ); + if (L > 1e-6) { + const epsU = Math.max(1e-4, 2 / L); + const u0 = Math.min(1 - epsU, radius / L + epsU); + linePoints = [ + balloonCenterX + + (anchorX - balloonCenterX) * u0, + balloonCenterY + + (anchorY - balloonCenterY) * u0, + anchorX, + anchorY + ]; + } + } + + return ( + + {/* Hit target: children use listening={false}, so without this rect + the group receives no pointer events (no hover cursor, no drag). */} + { + if (!pdfOverlayInteract) return; + const el = konvaContentFromTarget(e.target); + if (el) el.style.cursor = "grab"; + }} + onMouseLeave={(e) => { + const el = konvaContentFromTarget(e.target); + if (el) el.style.cursor = ""; + }} + onMouseDown={(e) => { + if (!pdfOverlayInteract) return; + e.cancelBubble = true; + setSelectedBalloonId(b.balloonId); + setSelectedSelectorId(b.selectorId); + beginBalloonPointerDrag( + b.balloonId, + { + x: b.x, + y: b.y, + width: b.width, + height: b.height + }, + renderedWidth, + overlayHeight, + totalPagesStage + ); + }} + /> + {linePoints && ( + + )} + + + + ); + })} + {pdfOverlayInteract && + selectedSelectorId && + (() => { + const s = selectorRects.find( + (x) => x.id === selectedSelectorId + ); + if (!s) return null; + const pageHeightPx = + overlayHeight / totalPagesStage; + const bx = (s.x / 100) * renderedWidth; + const by = + (s.pageNumber - 1) * pageHeightPx + + (s.y / 100) * pageHeightPx; + const bw = (s.width / 100) * renderedWidth; + const bh = (s.height / 100) * pageHeightPx; + const hitR = 7; + const handles: { + handle: ResizeHandleId; + cx: number; + cy: number; + }[] = [ + { handle: "nw", cx: bx, cy: by }, + { handle: "n", cx: bx + bw / 2, cy: by }, + { handle: "ne", cx: bx + bw, cy: by }, + { + handle: "e", + cx: bx + bw, + cy: by + bh / 2 + }, + { handle: "se", cx: bx + bw, cy: by + bh }, + { + handle: "s", + cx: bx + bw / 2, + cy: by + bh + }, + { handle: "sw", cx: bx, cy: by + bh }, + { + handle: "w", + cx: bx, + cy: by + bh / 2 + } + ]; + return handles.map(({ handle, cx, cy }) => ( + { + if (!pdfOverlayInteract) return; + const el = konvaContentFromTarget(e.target); + if (el) { + el.style.cursor = + cursorForResizeHandle(handle); + } + }} + onMouseLeave={(e) => { + const el = konvaContentFromTarget(e.target); + if (el) el.style.cursor = ""; + }} + onMouseDown={(e) => { + if (!pdfOverlayInteract) return; + e.cancelBubble = true; + beginSelectorResize( + s.id, + handle, + { + x: s.x, + y: s.y, + width: s.width, + height: s.height + }, + s.pageNumber, + renderedWidth, + overlayHeight, + totalPagesStage + ); + }} + /> + )); + })()} + {previewRect && ( + + )} + + +
+ )} +
+ ) : ( + + )} +
+ + {featuresTableExpanded ? (
0 ? containerWidth : "100%" }} - onMouseDown={handleMouseDown} - onMouseMove={handleMouseMove} - onMouseUp={handleMouseUp} - onMouseLeave={() => { - if (drag) setDrag(null); - if (movingBalloon) setMovingBalloon(null); - }} + role="separator" + aria-orientation="horizontal" + aria-label={t`Drag to resize diagram and features`} + className={`group flex h-2 shrink-0 cursor-row-resize touch-none items-center justify-center rounded-md px-2 hover:bg-muted/80 ${ + isResizingPdfFeatures ? "bg-muted" : "" + }`} + onMouseDown={onSplitResizeMouseDown} > - {isMounted && ( - setNumPages(numPages)} - onLoadError={(err) => - toast.error(`PDF error: ${err.message}`) - } - > - {Array.from({ length: numPages }, (_, i) => ( - 0 ? containerWidth : undefined} - renderTextLayer={false} - renderAnnotationLayer={false} - className="w-full" - /> - ))} - - )} - - {/* Saved highlights */} - {annotations.map((ann) => ( -
- ))} - - {/* Live drag preview */} - {previewRect && ( -
- )} + +
+ ) : null} - {/* Balloon number pins */} - {annotations.map((ann) => ( - - ))} -
- ) : ( - - )} -
- - {/* Feature table */} -
- - - - - - - - - - - - - - {sortedFeatures.map((feature) => ( - - setSelectedBalloon( - feature.id === selectedBalloon ? null : feature.id + {t`CSV`} + + + + ) : ( + ) } - className={`cursor-pointer ${selectedBalloon === feature.id ? "bg-primary/10" : ""}`} - > - - - - - - - - + + + + + + + + ))} + {featureRows.length === 0 && ( + + + + )} + +
{t`#`}{t`Description`}{t`Nominal`}{t`+Tol`}{t`-Tol`}{t`Unit`}{t`Characteristic`} -
- {feature.balloonNumber} - - - updateFeature(feature.id, "description", e.target.value) - } - placeholder={t`Feature description`} - onClick={(e) => e.stopPropagation()} - /> - - - updateFeature( - feature.id, - "nominalValue", - e.target.value === "" ? null : Number(e.target.value) - ) - } - placeholder="0.000" - onClick={(e) => e.stopPropagation()} - /> - - - updateFeature( - feature.id, - "tolerancePlus", - e.target.value === "" ? null : Number(e.target.value) - ) - } - placeholder="+0.005" - onClick={(e) => e.stopPropagation()} - /> - - - updateFeature( - feature.id, - "toleranceMinus", - e.target.value === "" ? null : Number(e.target.value) - ) - } - placeholder="-0.005" - onClick={(e) => e.stopPropagation()} - /> - - - updateFeature( - feature.id, - "unitOfMeasureCode", - e.target.value || null - ) - } - placeholder="mm" - onClick={(e) => e.stopPropagation()} - /> - e.stopPropagation()}> - - - + + updateFeatureField( + row.balloonId, + "label", + e.target.value + ) + } + aria-label={t`Balloon number`} + /> + + + updateFeatureField( + row.balloonId, + "featureName", + e.target.value + ) + } + aria-label={t`Feature`} + /> + + + updateFeatureField( + row.balloonId, + "nominalValue", + e.target.value + ) + } + aria-label={t`Nominal`} + /> + + + updateFeatureField( + row.balloonId, + "tolerancePlus", + e.target.value + ) + } + aria-label={t`Tolerance plus`} + /> + + + updateFeatureField( + row.balloonId, + "toleranceMinus", + e.target.value + ) + } + aria-label={t`Tolerance minus`} + /> + + + updateFeatureField( + row.balloonId, + "units", + e.target.value + ) + } + aria-label={t`Units`} + /> + + + } + onClick={(e) => { + e.stopPropagation(); + handleDeleteFeature(row.balloonId); + }} + /> +
+ {t`No features yet. Draw a selector to add a balloon, then use Save to persist. Open a saved diagram to load existing balloons.`} +
+
+
diff --git a/apps/erp/app/modules/quality/ui/Ballooning/BallooningForm.tsx b/apps/erp/app/modules/quality/ui/Ballooning/BallooningForm.tsx index 953c225b6..351ed5cf5 100644 --- a/apps/erp/app/modules/quality/ui/Ballooning/BallooningForm.tsx +++ b/apps/erp/app/modules/quality/ui/Ballooning/BallooningForm.tsx @@ -1,3 +1,4 @@ +import { useCarbon } from "@carbon/auth"; import { ValidatedForm } from "@carbon/form"; import { Button, @@ -7,10 +8,15 @@ import { DrawerFooter, DrawerHeader, DrawerTitle, + toast, VStack } from "@carbon/react"; import { useLingui } from "@lingui/react/macro"; +import { nanoid } from "nanoid"; +import { type ChangeEvent, useRef, useState } from "react"; +import { LuLoader, LuUpload } from "react-icons/lu"; import { Hidden, Input, Submit } from "~/components/Form"; +import { useUser } from "~/hooks"; import { ballooningDiagramValidator } from "~/modules/quality/quality.models"; import { path } from "~/utils/path"; @@ -29,7 +35,33 @@ export default function BallooningForm({ onClose }: BallooningFormProps) { const { t } = useLingui(); + const { carbon } = useCarbon(); + const user = useUser(); const isEditing = Boolean(initialValues.id); + const [pdfUrl, setPdfUrl] = useState(""); + const [uploading, setUploading] = useState(false); + const fileInputRef = useRef(null); + + const handlePdfUpload = async (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file || !carbon) return; + + setUploading(true); + const tempId = initialValues.id ?? nanoid(); + const storagePath = `${user.company.id}/ballooning/${tempId}/${nanoid()}.pdf`; + const result = await carbon.storage + .from("private") + .upload(storagePath, file); + setUploading(false); + + if (result.error) { + toast.error(t`Failed to upload PDF`); + return; + } + + setPdfUrl(`/file/preview/private/${result.data.path}`); + toast.success(t`PDF uploaded`); + }; return ( !open && onClose()}> @@ -55,6 +87,15 @@ export default function BallooningForm({ {isEditing && } + + + {!isEditing && ( + + )} - {isEditing ? t`Save` : t`Create`} + + {isEditing ? t`Save` : t`Create`} + diff --git a/apps/erp/app/modules/quality/ui/Ballooning/exportBallooningPdfWithOverlays.ts b/apps/erp/app/modules/quality/ui/Ballooning/exportBallooningPdfWithOverlays.ts new file mode 100644 index 000000000..554c71735 --- /dev/null +++ b/apps/erp/app/modules/quality/ui/Ballooning/exportBallooningPdfWithOverlays.ts @@ -0,0 +1,247 @@ +import { PDFDocument } from "pdf-lib"; +import { pdfjs } from "react-pdf"; + +const CALLOUT_STROKE = "#f97316"; +const CALLOUT_TEXT = "#171717"; + +function liangBarskySegmentRect( + x0: number, + y0: number, + x1: number, + y1: number, + minX: number, + minY: number, + maxX: number, + maxY: number +): { u0: number; u1: number } | null { + const dx = x1 - x0; + const dy = y1 - y0; + let u0 = 0; + let u1 = 1; + const p = [-dx, dx, -dy, dy]; + const q = [x0 - minX, maxX - x0, y0 - minY, maxY - y0]; + for (let i = 0; i < 4; i += 1) { + if (Math.abs(p[i]) < 1e-12) { + if (q[i] < 0) return null; + } else { + const r = q[i] / p[i]; + if (p[i] < 0) { + u0 = Math.max(u0, r); + } else { + u1 = Math.min(u1, r); + } + if (u0 > u1) return null; + } + } + return { u0, u1 }; +} + +function clippedBalloonToAnchorLine( + bx: number, + by: number, + radiusPx: number, + ax: number, + ay: number, + rect: { x: number; y: number; w: number; h: number } +): [number, number, number, number] | null { + const L = Math.hypot(ax - bx, ay - by); + if (L < 1e-6) return null; + const epsU = Math.max(1e-4, 2 / L); + const uBalloonExit = Math.min(1 - epsU, radiusPx / L + epsU); + const { x, y, w, h } = rect; + const hit = liangBarskySegmentRect(bx, by, ax, ay, x, y, x + w, y + h); + let uEnd = 1 - epsU; + if (hit) { + const uEnter = Math.max(0, Math.min(1, hit.u0)); + if (uEnter > uBalloonExit) { + uEnd = Math.min(uEnd, uEnter - epsU); + } + } + if (uEnd <= uBalloonExit + 1e-4) return null; + const x0 = bx + (ax - bx) * uBalloonExit; + const y0 = by + (ay - by) * uBalloonExit; + const x1 = bx + (ax - bx) * uEnd; + const y1 = by + (ay - by) * uEnd; + return [x0, y0, x1, y1]; +} + +export type ExportFeatureRow = { + balloonId: string; + selectorId: string; + label: string; + pageNumber: number; + x: number; + y: number; + width: number; + height: number; + anchorX: number; + anchorY: number; +}; + +export type ExportSelectorRect = { + id: string; + pageNumber: number; + x: number; + y: number; + width: number; + height: number; +}; + +function drawMarkupOnPageCanvas( + ctx: CanvasRenderingContext2D, + cw: number, + ch: number, + pageNumber: number, + featureRows: ExportFeatureRow[], + selectorRects: ExportSelectorRect[] +) { + ctx.save(); + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + + for (const s of selectorRects) { + if (s.pageNumber !== pageNumber) continue; + const sx = (s.x / 100) * cw; + const sy = (s.y / 100) * ch; + const sw = (s.width / 100) * cw; + const sh = (s.height / 100) * ch; + ctx.strokeStyle = CALLOUT_STROKE; + ctx.lineWidth = 2; + ctx.strokeRect(sx, sy, sw, sh); + } + + for (const b of featureRows) { + if (b.pageNumber !== pageNumber) continue; + const bw = (b.width / 100) * cw; + const bh = (b.height / 100) * ch; + const balloonX = (b.x / 100) * cw; + const balloonY = (b.y / 100) * ch; + const balloonCenterX = balloonX + bw / 2; + const balloonCenterY = balloonY + bh / 2; + const anchorX = (b.anchorX / 100) * cw; + const anchorY = (b.anchorY / 100) * ch; + const radius = Math.max(8, Math.min(bw, bh) / 2); + + const linkedSelector = selectorRects.find((s) => s.id === b.selectorId); + let linePoints: [number, number, number, number] | null = null; + if (linkedSelector) { + const sx = (linkedSelector.x / 100) * cw; + const sy = (linkedSelector.y / 100) * ch; + const sw = (linkedSelector.width / 100) * cw; + const sh = (linkedSelector.height / 100) * ch; + linePoints = clippedBalloonToAnchorLine( + balloonCenterX, + balloonCenterY, + radius, + anchorX, + anchorY, + { x: sx, y: sy, w: sw, h: sh } + ); + } else { + const L = Math.hypot(anchorX - balloonCenterX, anchorY - balloonCenterY); + if (L > 1e-6) { + const epsU = Math.max(1e-4, 2 / L); + const u0 = Math.min(1 - epsU, radius / L + epsU); + linePoints = [ + balloonCenterX + (anchorX - balloonCenterX) * u0, + balloonCenterY + (anchorY - balloonCenterY) * u0, + anchorX, + anchorY + ]; + } + } + + if (linePoints) { + ctx.beginPath(); + ctx.strokeStyle = CALLOUT_STROKE; + ctx.lineWidth = 2; + ctx.moveTo(linePoints[0], linePoints[1]); + ctx.lineTo(linePoints[2], linePoints[3]); + ctx.stroke(); + } + + ctx.beginPath(); + ctx.arc(balloonCenterX, balloonCenterY, radius, 0, Math.PI * 2); + ctx.strokeStyle = CALLOUT_STROKE; + ctx.lineWidth = 2; + ctx.fillStyle = "#ffffff"; + ctx.fill(); + ctx.stroke(); + + ctx.font = "bold 12px ui-sans-serif, system-ui, sans-serif"; + ctx.fillStyle = CALLOUT_TEXT; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(b.label, balloonCenterX, balloonCenterY); + } + + ctx.restore(); +} + +/** + * Rasterizes each PDF page with selector + balloon markup (matching the Konva overlay) and builds a new PDF. + */ +export async function buildBallooningPdfWithOverlaysBytes(args: { + pdfBytes: ArrayBuffer; + featureRows: ExportFeatureRow[]; + selectorRects: ExportSelectorRect[]; + /** PDF.js render scale; higher = sharper file */ + scale?: number; +}): Promise { + const scale = args.scale ?? 2; + const data = new Uint8Array(args.pdfBytes); + const pdf = await pdfjs.getDocument({ data }).promise; + const outDoc = await PDFDocument.create(); + + try { + const numPages = pdf.numPages; + for (let pageNum = 1; pageNum <= numPages; pageNum += 1) { + const page = await pdf.getPage(pageNum); + const viewport = page.getViewport({ scale }); + const cw = Math.floor(viewport.width); + const ch = Math.floor(viewport.height); + const canvas = document.createElement("canvas"); + canvas.width = cw; + canvas.height = ch; + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new Error("Could not get canvas context"); + } + + const renderTask = page.render({ + canvasContext: ctx, + viewport + }); + await renderTask.promise; + + drawMarkupOnPageCanvas( + ctx, + cw, + ch, + pageNum, + args.featureRows, + args.selectorRects + ); + + const blob = await new Promise((resolve, reject) => { + canvas.toBlob((b) => { + if (b) resolve(b); + else reject(new Error("Canvas toBlob failed")); + }, "image/png"); + }); + const pngBytes = new Uint8Array(await blob.arrayBuffer()); + const image = await outDoc.embedPng(pngBytes); + const pdfPage = outDoc.addPage([cw, ch]); + pdfPage.drawImage(image, { + x: 0, + y: 0, + width: cw, + height: ch + }); + } + + return await outDoc.save({ useObjectStreams: true }); + } finally { + await pdf.destroy(); + } +} diff --git a/apps/erp/app/modules/quality/ui/Ballooning/index.ts b/apps/erp/app/modules/quality/ui/Ballooning/index.ts index 5583038ef..952c86c4d 100644 --- a/apps/erp/app/modules/quality/ui/Ballooning/index.ts +++ b/apps/erp/app/modules/quality/ui/Ballooning/index.ts @@ -1,3 +1,8 @@ -export { default as BalloonDiagramEditor } from "./BalloonDiagramEditor"; +/** + * Do not barrel-export BalloonDiagramEditor: it depends on react-konva → Konva + * Node build → `require("canvas")`, which breaks Vite SSR for any route that + * only imports BallooningForm / BallooningTable from this file. + * Import the editor only via direct path + lazy/ClientOnly (see ballooning-diagram/$id). + */ export { default as BallooningForm } from "./BallooningForm"; export { default as BallooningTable } from "./BallooningTable"; diff --git a/apps/erp/app/routes/api+/mcp+/lib/server.ts b/apps/erp/app/routes/api+/mcp+/lib/server.ts index 551dad1f4..fe44e3e36 100644 --- a/apps/erp/app/routes/api+/mcp+/lib/server.ts +++ b/apps/erp/app/routes/api+/mcp+/lib/server.ts @@ -39,7 +39,7 @@ Tools are namespaced by module — use the prefix to discover related tools: - people_* — 24 read, 14 write, 6 delete tools - production_* — 62 read, 45 write, 22 delete tools - purchasing_* — 51 read, 34 write, 11 delete tools -- quality_* — 40 read, 20 write, 12 delete tools +- quality_* — 42 read, 25 write, 14 delete tools - resources_* — 47 read, 27 write, 20 delete tools - sales_* — 78 read, 54 write, 18 delete tools - settings_* — 26 read, 40 write, 2 delete tools diff --git a/apps/erp/app/routes/api+/mcp+/lib/tools/quality.ts b/apps/erp/app/routes/api+/mcp+/lib/tools/quality.ts index 56d2933e9..30d934259 100644 --- a/apps/erp/app/routes/api+/mcp+/lib/tools/quality.ts +++ b/apps/erp/app/routes/api+/mcp+/lib/tools/quality.ts @@ -82,6 +82,15 @@ import { getBallooningDiagram, upsertBallooningDiagram, deleteBallooningDiagram, + getBallooningSelectors, + createBallooningSelectors, + updateBallooningSelectors, + deleteBallooningSelectors, + getBallooningBalloons, + createBalloonsForSelectors, + createBallooningBalloonsFromPayload, + updateBallooningBalloons, + deleteBallooningBalloons, } from "~/modules/quality/quality.service"; import { nonConformanceReviewerValidator, @@ -1236,4 +1245,194 @@ export const registerQualityTools: RegisterTools = (server, ctx) => { return toMcpResult(result); }, "Failed: quality_deleteBallooningDiagram"), ); + + server.registerTool( + "quality_getBallooningSelectors", + { + description: "get ballooning selectors", + inputSchema: { + drawingId: z.string(), + }, + annotations: READ_ONLY_ANNOTATIONS, + }, + withErrorHandling(async (params) => { + const result = await getBallooningSelectors(ctx.client, params.drawingId); + return toMcpResult(result); + }, "Failed: quality_getBallooningSelectors"), + ); + + server.registerTool( + "quality_createBallooningSelectors", + { + description: "create ballooning selectors", + inputSchema: { + args: z.object({ + drawingId: z.string(), + selectors: z.any(), + pageNumber: z.number(), + xCoordinate: z.number(), + yCoordinate: z.number(), + width: z.number(), + height: z.number() + }), + }, + annotations: WRITE_ANNOTATIONS, + }, + withErrorHandling(async (params) => { + const result = await createBallooningSelectors(ctx.client, { ...params.args, companyId: ctx.companyId, createdBy: ctx.userId }); + return toMcpResult(result); + }, "Failed: quality_createBallooningSelectors"), + ); + + server.registerTool( + "quality_updateBallooningSelectors", + { + description: "update ballooning selectors", + inputSchema: { + args: z.object({ + drawingId: z.string(), + selectors: z.any(), + id: z.string(), + pageNumber: z.number().optional(), + xCoordinate: z.number().optional(), + yCoordinate: z.number().optional(), + width: z.number().optional(), + height: z.number().optional() + }), + }, + annotations: WRITE_ANNOTATIONS, + }, + withErrorHandling(async (params) => { + const result = await updateBallooningSelectors(ctx.client, { ...params.args, companyId: ctx.companyId, updatedBy: ctx.userId }); + return toMcpResult(result); + }, "Failed: quality_updateBallooningSelectors"), + ); + + server.registerTool( + "quality_deleteBallooningSelectors", + { + description: "delete ballooning selectors", + inputSchema: { + args: z.object({ + drawingId: z.string(), + ids: z.array(z.string()) + }), + }, + annotations: DESTRUCTIVE_ANNOTATIONS, + }, + withErrorHandling(async (params) => { + const result = await deleteBallooningSelectors(ctx.client, { ...params.args, companyId: ctx.companyId, updatedBy: ctx.userId }); + return toMcpResult(result); + }, "Failed: quality_deleteBallooningSelectors"), + ); + + server.registerTool( + "quality_getBallooningBalloons", + { + description: "get ballooning balloons", + inputSchema: { + drawingId: z.string(), + }, + annotations: READ_ONLY_ANNOTATIONS, + }, + withErrorHandling(async (params) => { + const result = await getBallooningBalloons(ctx.client, params.drawingId); + return toMcpResult(result); + }, "Failed: quality_getBallooningBalloons"), + ); + + server.registerTool( + "quality_createBalloonsForSelectors", + { + description: "create balloons for selectors", + inputSchema: { + args: z.object({ + drawingId: z.string(), + selectors: z.any(), + id: z.string(), + pageNumber: z.number(), + xCoordinate: z.number(), + yCoordinate: z.number(), + width: z.number(), + height: z.number() + }), + }, + annotations: WRITE_ANNOTATIONS, + }, + withErrorHandling(async (params) => { + const result = await createBalloonsForSelectors(ctx.client, { ...params.args, companyId: ctx.companyId, createdBy: ctx.userId }); + return toMcpResult(result); + }, "Failed: quality_createBalloonsForSelectors"), + ); + + server.registerTool( + "quality_createBallooningBalloonsFromPayload", + { + description: "create ballooning balloons from payload", + inputSchema: { + args: z.object({ + drawingId: z.string(), + selectorIdMap: z.any(), + balloons: z.any(), + tempSelectorId: z.string(), + label: z.string(), + xCoordinate: z.number(), + yCoordinate: z.number(), + anchorX: z.number(), + anchorY: z.number(), + data: z.any(), + description: z.string().nullable().optional() + }), + }, + annotations: WRITE_ANNOTATIONS, + }, + withErrorHandling(async (params) => { + const result = await createBallooningBalloonsFromPayload(ctx.client, { ...params.args, companyId: ctx.companyId, createdBy: ctx.userId }); + return toMcpResult(result); + }, "Failed: quality_createBallooningBalloonsFromPayload"), + ); + + server.registerTool( + "quality_updateBallooningBalloons", + { + description: "update ballooning balloons", + inputSchema: { + args: z.object({ + drawingId: z.string(), + balloons: z.any(), + id: z.string(), + label: z.string().optional(), + xCoordinate: z.number().optional(), + yCoordinate: z.number().optional(), + anchorX: z.number().optional(), + anchorY: z.number().optional(), + data: z.any().optional(), + description: z.string().nullable().optional() + }), + }, + annotations: WRITE_ANNOTATIONS, + }, + withErrorHandling(async (params) => { + const result = await updateBallooningBalloons(ctx.client, { ...params.args, companyId: ctx.companyId, updatedBy: ctx.userId }); + return toMcpResult(result); + }, "Failed: quality_updateBallooningBalloons"), + ); + + server.registerTool( + "quality_deleteBallooningBalloons", + { + description: "delete ballooning balloons", + inputSchema: { + args: z.object({ + drawingId: z.string(), + ids: z.array(z.string()) + }), + }, + annotations: DESTRUCTIVE_ANNOTATIONS, + }, + withErrorHandling(async (params) => { + const result = await deleteBallooningBalloons(ctx.client, { ...params.args, companyId: ctx.companyId, updatedBy: ctx.userId }); + return toMcpResult(result); + }, "Failed: quality_deleteBallooningBalloons"), + ); }; diff --git a/apps/erp/app/routes/x+/ballooning-diagram+/$id.save.tsx b/apps/erp/app/routes/x+/ballooning-diagram+/$id.save.tsx index db439837e..47318e795 100644 --- a/apps/erp/app/routes/x+/ballooning-diagram+/$id.save.tsx +++ b/apps/erp/app/routes/x+/ballooning-diagram+/$id.save.tsx @@ -2,11 +2,22 @@ import { assertIsPost } from "@carbon/auth"; import { requirePermissions } from "@carbon/auth/auth.server"; import type { ActionFunctionArgs } from "react-router"; import { data } from "react-router"; -import { upsertBallooningDiagram } from "~/modules/quality"; +import { + createBallooningBalloonsFromPayload, + createBallooningSelectors, + createBalloonsForSelectors, + deleteBallooningBalloons, + deleteBallooningSelectors, + getBallooningBalloons, + getBallooningSelectors, + updateBallooningBalloons, + updateBallooningSelectors, + upsertBallooningDiagram +} from "~/modules/quality"; export async function action({ request, params }: ActionFunctionArgs) { assertIsPost(request); - const { client, userId } = await requirePermissions(request, { + const { client, userId, companyId } = await requirePermissions(request, { update: "quality" }); @@ -17,15 +28,32 @@ export async function action({ request, params }: ActionFunctionArgs) { const formData = await request.formData(); const name = formData.get("name") as string; const pdfUrl = formData.get("pdfUrl") as string | null; - const annotations = formData.get("annotations") as string | null; - const features = formData.get("features") as string | null; + const selectorsRaw = formData.get("selectors") as string | null; + const balloonsRaw = formData.get("balloons") as string | null; + const pageCountRaw = formData.get("pageCount"); + const defaultPageWidthRaw = formData.get("defaultPageWidth"); + const defaultPageHeightRaw = formData.get("defaultPageHeight"); + + const pageCount = + typeof pageCountRaw === "string" && pageCountRaw + ? Number(pageCountRaw) + : undefined; + const defaultPageWidth = + typeof defaultPageWidthRaw === "string" && defaultPageWidthRaw + ? Number(defaultPageWidthRaw) + : undefined; + const defaultPageHeight = + typeof defaultPageHeightRaw === "string" && defaultPageHeightRaw + ? Number(defaultPageHeightRaw) + : undefined; const result = await upsertBallooningDiagram(client, { id, name, pdfUrl: pdfUrl ?? undefined, - annotations: annotations ?? undefined, - features: features ?? undefined, + pageCount, + defaultPageWidth, + defaultPageHeight, companyId: "", createdBy: userId, updatedBy: userId @@ -38,5 +66,376 @@ export async function action({ request, params }: ActionFunctionArgs) { ); } - return { success: true }; + type BalloonCreatePayload = { + tempSelectorId: string; + label: string; + xCoordinate: number; + yCoordinate: number; + anchorX: number; + anchorY: number; + data: Record; + description?: string | null; + }; + type BalloonUpdatePayload = { + id: string; + label?: string; + xCoordinate?: number; + yCoordinate?: number; + anchorX?: number; + anchorY?: number; + data?: Record; + description?: string | null; + }; + + let balloonsParsed: { + create: BalloonCreatePayload[]; + update: BalloonUpdatePayload[]; + } | null = null; + + let balloonDeleteIds: string[] = []; + let selectorDeleteIds: string[] = []; + + if (balloonsRaw) { + try { + const json = JSON.parse(balloonsRaw) as unknown; + if (typeof json !== "object" || json === null) { + throw new Error("Invalid balloons payload"); + } + const deleteJson = (json as { delete?: unknown }).delete; + if (Array.isArray(deleteJson)) { + balloonDeleteIds = deleteJson.filter( + (x): x is string => typeof x === "string" && x.length > 0 + ); + } + const createJson = (json as { create?: unknown }).create; + const updateJson = (json as { update?: unknown }).update; + const create: BalloonCreatePayload[] = []; + const update: BalloonUpdatePayload[] = []; + + if (Array.isArray(createJson)) { + for (const item of createJson) { + if ( + typeof item === "object" && + item !== null && + typeof (item as { tempSelectorId?: unknown }).tempSelectorId === + "string" && + typeof (item as { label?: unknown }).label === "string" && + typeof (item as { xCoordinate?: unknown }).xCoordinate === + "number" && + typeof (item as { yCoordinate?: unknown }).yCoordinate === + "number" && + typeof (item as { anchorX?: unknown }).anchorX === "number" && + typeof (item as { anchorY?: unknown }).anchorY === "number" && + typeof (item as { data?: unknown }).data === "object" && + (item as { data?: unknown }).data !== null + ) { + create.push({ + tempSelectorId: (item as { tempSelectorId: string }) + .tempSelectorId, + label: (item as { label: string }).label, + xCoordinate: (item as { xCoordinate: number }).xCoordinate, + yCoordinate: (item as { yCoordinate: number }).yCoordinate, + anchorX: (item as { anchorX: number }).anchorX, + anchorY: (item as { anchorY: number }).anchorY, + data: (item as { data: Record }).data, + description: + (item as { description?: unknown }).description === undefined + ? undefined + : (item as { description: string | null }).description + }); + } + } + } + + if (Array.isArray(updateJson)) { + for (const item of updateJson) { + if ( + typeof item === "object" && + item !== null && + typeof (item as { id?: unknown }).id === "string" + ) { + const u = item as Record; + update.push({ + id: String(u.id), + ...(typeof u.label === "string" ? { label: u.label } : {}), + ...(typeof u.xCoordinate === "number" + ? { xCoordinate: u.xCoordinate } + : {}), + ...(typeof u.yCoordinate === "number" + ? { yCoordinate: u.yCoordinate } + : {}), + ...(typeof u.anchorX === "number" ? { anchorX: u.anchorX } : {}), + ...(typeof u.anchorY === "number" ? { anchorY: u.anchorY } : {}), + ...(typeof u.data === "object" && + u.data !== null && + !Array.isArray(u.data) + ? { data: u.data as Record } + : {}), + ...(u.description !== undefined + ? { description: u.description as string | null } + : {}) + }); + } + } + } + + balloonsParsed = { create, update }; + } catch { + return data( + { success: false, message: "Invalid balloons payload" }, + { status: 400 } + ); + } + } + + if (balloonDeleteIds.length > 0) { + const delBalloons = await deleteBallooningBalloons(client, { + drawingId: id, + companyId, + updatedBy: userId, + ids: balloonDeleteIds + }); + if (delBalloons.error) { + return data( + { success: false, message: "Failed to delete balloons" }, + { status: 400 } + ); + } + } + + let selectorIdMap: Record = {}; + if (selectorsRaw) { + let parsed: { + create: Array<{ + tempId: string; + pageNumber: number; + xCoordinate: number; + yCoordinate: number; + width: number; + height: number; + }>; + update: Array<{ + id: string; + pageNumber?: number; + xCoordinate?: number; + yCoordinate?: number; + width?: number; + height?: number; + }>; + } = { + create: [], + update: [] + }; + + try { + const json = JSON.parse(selectorsRaw) as unknown; + if (typeof json !== "object" || json === null) { + throw new Error("Invalid selectors payload"); + } + + const selDeleteJson = (json as { delete?: unknown }).delete; + if (Array.isArray(selDeleteJson)) { + selectorDeleteIds = selDeleteJson.filter( + (x): x is string => typeof x === "string" && x.length > 0 + ); + } + + const createJson = (json as { create?: unknown }).create; + const updateJson = (json as { update?: unknown }).update; + + if (Array.isArray(createJson)) { + parsed.create = createJson.filter( + ( + s + ): s is { + tempId: string; + pageNumber: number; + xCoordinate: number; + yCoordinate: number; + width: number; + height: number; + } => + typeof s === "object" && + s !== null && + typeof (s as { tempId?: unknown }).tempId === "string" && + typeof (s as { pageNumber?: unknown }).pageNumber === "number" && + typeof (s as { xCoordinate?: unknown }).xCoordinate === "number" && + typeof (s as { yCoordinate?: unknown }).yCoordinate === "number" && + typeof (s as { width?: unknown }).width === "number" && + typeof (s as { height?: unknown }).height === "number" + ); + } + + if (Array.isArray(updateJson)) { + parsed.update = updateJson.filter( + ( + s + ): s is { + id: string; + pageNumber?: number; + xCoordinate?: number; + yCoordinate?: number; + width?: number; + height?: number; + } => + typeof s === "object" && + s !== null && + typeof (s as { id?: unknown }).id === "string" + ); + } + } catch { + return data( + { success: false, message: "Invalid selectors payload" }, + { status: 400 } + ); + } + + if (selectorDeleteIds.length > 0) { + const delSelectors = await deleteBallooningSelectors(client, { + drawingId: id, + companyId, + updatedBy: userId, + ids: selectorDeleteIds + }); + if (delSelectors.error) { + return data( + { success: false, message: "Failed to delete selectors" }, + { status: 400 } + ); + } + } + + const createSelectorsResult = await createBallooningSelectors(client, { + drawingId: id, + companyId, + createdBy: userId, + selectors: parsed.create.map((s) => ({ + pageNumber: s.pageNumber, + xCoordinate: s.xCoordinate, + yCoordinate: s.yCoordinate, + width: s.width, + height: s.height + })) + }); + + if (createSelectorsResult.error) { + return data( + { success: false, message: "Failed to create selectors" }, + { status: 400 } + ); + } + + const insertedSelectors = (createSelectorsResult.data ?? []).map((s) => ({ + id: String(s.id), + pageNumber: Number(s.pageNumber), + xCoordinate: Number(s.xCoordinate), + yCoordinate: Number(s.yCoordinate), + width: Number(s.width), + height: Number(s.height) + })); + + for (let i = 0; i < parsed.create.length; i += 1) { + const tempId = parsed.create[i]?.tempId; + const inserted = insertedSelectors[i]; + if (tempId && inserted?.id) { + selectorIdMap[tempId] = inserted.id; + } + } + + if (balloonsParsed?.create?.length) { + const fromPayload = await createBallooningBalloonsFromPayload(client, { + drawingId: id, + companyId, + createdBy: userId, + selectorIdMap, + balloons: balloonsParsed.create.map((b) => ({ + tempSelectorId: b.tempSelectorId, + label: b.label, + xCoordinate: b.xCoordinate, + yCoordinate: b.yCoordinate, + anchorX: b.anchorX, + anchorY: b.anchorY, + data: b.data, + description: + b.description ?? + (typeof b.data["featureName"] === "string" + ? b.data["featureName"] + : null) + })) + }); + + if (fromPayload.error) { + return data( + { success: false, message: "Failed to create balloons" }, + { status: 400 } + ); + } + } else if (insertedSelectors.length > 0) { + const createBalloonsResult = await createBalloonsForSelectors(client, { + drawingId: id, + companyId, + createdBy: userId, + selectors: insertedSelectors + }); + + if (createBalloonsResult.error) { + return data( + { success: false, message: "Failed to create balloons" }, + { status: 400 } + ); + } + } + + const updateSelectorsResult = await updateBallooningSelectors(client, { + drawingId: id, + companyId, + updatedBy: userId, + selectors: parsed.update + }); + + if (updateSelectorsResult.error) { + return data( + { success: false, message: "Failed to update selectors" }, + { status: 400 } + ); + } + } + + if (balloonsParsed?.update?.length) { + const updateBalloonsResult = await updateBallooningBalloons(client, { + drawingId: id, + companyId, + updatedBy: userId, + balloons: balloonsParsed.update + }); + + if (updateBalloonsResult.error) { + return data( + { success: false, message: "Failed to update balloons" }, + { status: 400 } + ); + } + } + + const [selectorsResult, balloonsResult] = await Promise.all([ + getBallooningSelectors(client, id), + getBallooningBalloons(client, id) + ]); + + if (selectorsResult.error || balloonsResult.error) { + return data( + { + success: false, + message: "Saved but failed to reload persisted ballooning data" + }, + { status: 500 } + ); + } + + return { + success: true, + selectorIdMap, + selectors: selectorsResult.data ?? [], + balloons: balloonsResult.data ?? [] + }; } diff --git a/apps/erp/app/routes/x+/ballooning-diagram+/$id.tsx b/apps/erp/app/routes/x+/ballooning-diagram+/$id.tsx index 612aa2b71..cbc71e876 100644 --- a/apps/erp/app/routes/x+/ballooning-diagram+/$id.tsx +++ b/apps/erp/app/routes/x+/ballooning-diagram+/$id.tsx @@ -2,15 +2,25 @@ import { error } from "@carbon/auth"; import { requirePermissions } from "@carbon/auth/auth.server"; import { getCarbonServiceRole } from "@carbon/auth/client.server"; import { flash } from "@carbon/auth/session.server"; +import { ClientOnly, Spinner } from "@carbon/react"; import { msg } from "@lingui/core/macro"; +import { lazy, Suspense } from "react"; import type { LoaderFunctionArgs } from "react-router"; import { redirect, useLoaderData } from "react-router"; -import { getBallooningDiagram } from "~/modules/quality"; +import { + getBallooningBalloons, + getBallooningDiagram, + getBallooningSelectors +} from "~/modules/quality"; import type { BallooningDiagramContent } from "~/modules/quality/types"; -import { BalloonDiagramEditor } from "~/modules/quality/ui/Ballooning"; import type { Handle } from "~/utils/handle"; import { path } from "~/utils/path"; +/** Konva must not load on the server (it requires native `canvas`). */ +const BalloonDiagramEditor = lazy( + () => import("~/modules/quality/ui/Ballooning/BalloonDiagramEditor") +); + export const handle: Handle = { breadcrumb: msg`Ballooning Diagrams`, to: path.to.ballooningDiagrams, @@ -26,7 +36,11 @@ export async function loader({ request, params }: LoaderFunctionArgs) { if (!id) throw new Error("Could not find id"); const serviceRole = await getCarbonServiceRole(); - const diagram = await getBallooningDiagram(serviceRole, id); + const [diagram, selectors, balloons] = await Promise.all([ + getBallooningDiagram(serviceRole, id), + getBallooningSelectors(serviceRole, id), + getBallooningBalloons(serviceRole, id) + ]); if (diagram.error) { throw redirect( @@ -42,20 +56,44 @@ export async function loader({ request, params }: LoaderFunctionArgs) { throw redirect(path.to.ballooningDiagrams); } - return { diagram: diagram.data }; + return { + diagram: diagram.data, + selectors: selectors.data ?? [], + balloons: balloons.data ?? [] + }; } export default function BallooningDetailRoute() { - const { diagram } = useLoaderData(); + const { diagram, selectors, balloons } = useLoaderData(); const content = diagram.content as BallooningDiagramContent | null; return (
- + + +
+ } + > + {() => ( + + +
+ } + > + + + )} + ); } diff --git a/apps/erp/app/ssr-shims/canvas-stub.cjs b/apps/erp/app/ssr-shims/canvas-stub.cjs new file mode 100644 index 000000000..ec941e655 --- /dev/null +++ b/apps/erp/app/ssr-shims/canvas-stub.cjs @@ -0,0 +1,62 @@ +/** + * Stub for Node's `canvas` package. Konva's `lib/index-node.js` requires it + * when Vite SSR evaluates `konva`; we avoid installing native `canvas`. + * SSR does not render Konva output; this only needs to load without throwing. + */ +"use strict"; + +class ImageStub { + constructor() { + this.src = ""; + this.onload = null; + this.onerror = null; + } +} + +function createCanvas() { + return { + width: 300, + height: 300, + style: {}, + getContext() { + return { + canvas: null, + fillRect() {}, + clearRect() {}, + drawImage() {}, + fill() {}, + stroke() {}, + beginPath() {}, + closePath() {}, + moveTo() {}, + lineTo() {}, + rect() {}, + clip() {}, + save() {}, + restore() {}, + translate() {}, + scale() {}, + rotate() {}, + measureText() { + return { width: 0 }; + } + }; + } + }; +} + +const DOMMatrixStub = + typeof globalThis.DOMMatrix !== "undefined" + ? globalThis.DOMMatrix + : class DOMMatrixStubInner { + constructor() {} + }; + +const api = { + createCanvas, + Image: ImageStub, + DOMMatrix: DOMMatrixStub +}; + +module.exports = api; +module.exports.default = api; diff --git a/apps/erp/package.json b/apps/erp/package.json index 46d903d7e..ce33550f2 100644 --- a/apps/erp/package.json +++ b/apps/erp/package.json @@ -66,6 +66,7 @@ "inngest": "^3.52.7", "isbot": "5", "json-2-csv": "^5.5.10", + "konva": "^9.3.22", "localforage": "^1.10.0", "lodash.debounce": "^4.0.8", "lodash.words": "^4.2.0", @@ -75,6 +76,7 @@ "nanostores": "^0.9.3", "non.geist": "~1.0.3", "papaparse": "5.3.2", + "pdf-lib": "^1.17.1", "performant-array-to-tree": "1.11.0", "posthog-js": "^1.116.6", "react": "^18.3.1", @@ -84,6 +86,7 @@ "react-hotkeys-hook": "~4.5.0", "react-icons": "^5.4.0", "react-intersection-observer": "^9.13.1", + "react-konva": "^18.2.10", "react-markdown": "^9.0.1", "react-pdf": "^10.4.1", "react-router": "7.12.0", @@ -100,6 +103,7 @@ "tailwindcss-animate": "^1.0.7", "tsup": "8.5.1", "use-stick-to-bottom": "^1.1.1", + "xlsx": "^0.18.5", "zod": "^3.25.76", "zod-form-data": "2.0.8", "zod-to-json-schema": "^3.24.6", diff --git a/apps/erp/vite.config.ts b/apps/erp/vite.config.ts index 1519ea740..0f4b8163e 100644 --- a/apps/erp/vite.config.ts +++ b/apps/erp/vite.config.ts @@ -43,6 +43,12 @@ export default defineConfig(({ isSsrBuild }) => ({ ] as PluginOption[], resolve: { alias: { + /** + * Konva's Node entry (`index-node.js`) requires native `canvas`. Vite SSR + * can still load that graph; alias `canvas` to a stub (do not alias the + * whole `konva` package — react-konva imports `konva/lib/Core.js`, etc.). + */ + canvas: path.resolve(__dirname, "app/ssr-shims/canvas-stub.cjs"), "@carbon/utils": path.resolve( __dirname, "../../packages/utils/src/index.ts" diff --git a/llm/cache/mcp-tools-reference.md b/llm/cache/mcp-tools-reference.md index dfdbcfb09..a1fa0d101 100644 --- a/llm/cache/mcp-tools-reference.md +++ b/llm/cache/mcp-tools-reference.md @@ -1,7 +1,7 @@ # Carbon ERP MCP Tools Reference > Auto-generated by scripts/generate-mcp.ts -> Total: 1021 tools across 15 modules +> Total: 1030 tools across 15 modules ## account (10 tools) @@ -4179,7 +4179,7 @@ get purchasing r f q suppliers with links --- -## quality (72 tools) +## quality (81 tools) ### quality_activateGauge (WRITE) activate gauge @@ -4666,19 +4666,148 @@ get ballooning diagram ### quality_upsertBallooningDiagram (WRITE) upsert ballooning diagram **Parameters:** -- `diagram`: Omit, "id"> & { - id?: string; - companyId: string; - createdBy: string; - updatedBy?: string; - features?: string; - } +- `diagram`: | (Omit, "id"> & { + id?: undefined; + companyId: string; + createdBy: string; + pageCount?: number; + defaultPageWidth?: number; + defaultPageHeight?: number; + }) + | (Omit, "id"> & { + id: string; + companyId?: string; + createdBy: string; + updatedBy?: string; + pageCount?: number; + defaultPageWidth?: number; + defaultPageHeight?: number; + }) ### quality_deleteBallooningDiagram (DESTRUCTIVE) delete ballooning diagram **Parameters:** - `id`: string +### quality_getBallooningSelectors (READ) +get ballooning selectors +**Parameters:** +- `drawingId`: string + +### quality_createBallooningSelectors (WRITE) +create ballooning selectors +**Parameters:** +- `args`: { + drawingId: string; + companyId: string; + createdBy: string; + selectors: { + pageNumber: number; + xCoordinate: number; + yCoordinate: number; + width: number; + height: number; + }[]; + } + +### quality_updateBallooningSelectors (WRITE) +update ballooning selectors +**Parameters:** +- `args`: { + drawingId: string; + companyId: string; + updatedBy: string; + selectors: { + id: string; + pageNumber?: number; + xCoordinate?: number; + yCoordinate?: number; + width?: number; + height?: number; + }[]; + } + +### quality_deleteBallooningSelectors (DESTRUCTIVE) +delete ballooning selectors +**Parameters:** +- `args`: { + drawingId: string; + companyId: string; + updatedBy: string; + ids: string[]; + } + +### quality_getBallooningBalloons (READ) +get ballooning balloons +**Parameters:** +- `drawingId`: string + +### quality_createBalloonsForSelectors (WRITE) +create balloons for selectors +**Parameters:** +- `args`: { + drawingId: string; + companyId: string; + createdBy: string; + selectors: { + id: string; + pageNumber: number; + xCoordinate: number; + yCoordinate: number; + width: number; + height: number; + }[]; + } + +### quality_createBallooningBalloonsFromPayload (WRITE) +create ballooning balloons from payload +**Parameters:** +- `args`: { + drawingId: string; + companyId: string; + createdBy: string; + selectorIdMap: Record; + balloons: Array<{ + tempSelectorId: string; + label: string; + xCoordinate: number; + yCoordinate: number; + anchorX: number; + anchorY: number; + data: Record; + description?: string | null; + }>; + } + +### quality_updateBallooningBalloons (WRITE) +update ballooning balloons +**Parameters:** +- `args`: { + drawingId: string; + companyId: string; + updatedBy: string; + balloons: Array<{ + id: string; + label?: string; + xCoordinate?: number; + yCoordinate?: number; + anchorX?: number; + anchorY?: number; + data?: Record; + description?: string | null; + }>; + } + +### quality_deleteBallooningBalloons (DESTRUCTIVE) +delete ballooning balloons +**Parameters:** +- `args`: { + drawingId: string; + companyId: string; + updatedBy: string; + ids: string[]; + } + --- ## resources (94 tools) diff --git a/package-lock.json b/package-lock.json index bab574715..f335165a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -270,6 +270,7 @@ "inngest": "^3.52.7", "isbot": "5", "json-2-csv": "^5.5.10", + "konva": "^9.3.22", "localforage": "^1.10.0", "lodash.debounce": "^4.0.8", "lodash.words": "^4.2.0", @@ -279,6 +280,7 @@ "nanostores": "^0.9.3", "non.geist": "~1.0.3", "papaparse": "5.3.2", + "pdf-lib": "^1.17.1", "performant-array-to-tree": "1.11.0", "posthog-js": "^1.116.6", "react": "^18.3.1", @@ -288,6 +290,7 @@ "react-hotkeys-hook": "~4.5.0", "react-icons": "^5.4.0", "react-intersection-observer": "^9.13.1", + "react-konva": "^18.2.10", "react-markdown": "^9.0.1", "react-pdf": "^10.4.1", "react-router": "7.12.0", @@ -304,6 +307,7 @@ "tailwindcss-animate": "^1.0.7", "tsup": "8.5.1", "use-stick-to-bottom": "^1.1.1", + "xlsx": "^0.18.5", "zod": "^3.25.76", "zod-form-data": "2.0.8", "zod-to-json-schema": "^3.24.6", @@ -11113,6 +11117,24 @@ "@opentelemetry/api": "^1.1.0" } }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -19013,6 +19035,15 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-transition-group": { "version": "4.4.12", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", @@ -20041,6 +20072,15 @@ "acorn": "^8" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/agent-base": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-8.0.0.tgz", @@ -20861,6 +20901,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -21440,6 +21493,15 @@ "dev": true, "license": "MIT" }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -21722,6 +21784,18 @@ "integrity": "sha512-92HoA8l6DluEidku8tKBftjuFRj4Rv3zDW1lXxCuNnqAxhUSkvso9gM/Afj4F5BnK+wneHIe3ydI+s+4NA29/Q==", "license": "MIT" }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", @@ -23878,6 +23952,15 @@ "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==", "license": "MIT" }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -25777,6 +25860,18 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/its-fine": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz", + "integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.0" + }, + "peerDependencies": { + "react": ">=18.0" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -26275,6 +26370,26 @@ "graceful-fs": "^4.1.11" } }, + "node_modules/konva": { + "version": "9.3.22", + "resolved": "https://registry.npmjs.org/konva/-/konva-9.3.22.tgz", + "integrity": "sha512-yQI5d1bmELlD/fowuyfOp9ff+oamg26WOCkyqUyc+nczD/lhRa3EvD2MZOoc4c1293TAubW9n34fSQLgSeEgSw==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/lavrton" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/konva" + }, + { + "type": "github", + "url": "https://github.com/sponsors/lavrton" + } + ], + "license": "MIT" + }, "node_modules/kysely": { "version": "0.28.15", "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.15.tgz", @@ -29391,6 +29506,24 @@ "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", "license": "MIT" }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdf-lib/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/pdfjs-dist": { "version": "5.4.296", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", @@ -30850,6 +30983,37 @@ "react": "^18.0.0 || ^19.0.0" } }, + "node_modules/react-konva": { + "version": "18.2.10", + "resolved": "https://registry.npmjs.org/react-konva/-/react-konva-18.2.10.tgz", + "integrity": "sha512-ohcX1BJINL43m4ynjZ24MxFI1syjBdrXhqVxYVDw2rKgr3yuS0x/6m1Y2Z4sl4T/gKhfreBx8KHisd0XC6OT1g==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/lavrton" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/konva" + }, + { + "type": "github", + "url": "https://github.com/sponsors/lavrton" + } + ], + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.2", + "its-fine": "^1.1.1", + "react-reconciler": "~0.29.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "konva": "^8.0.1 || ^7.2.5 || ^9.0.0", + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, "node_modules/react-markdown": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz", @@ -30969,6 +31133,22 @@ "integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==", "license": "MIT" }, + "node_modules/react-reconciler": { + "version": "0.29.2", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz", + "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", @@ -32688,6 +32868,18 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/sst": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/sst/-/sst-4.7.0.tgz", @@ -35179,6 +35371,24 @@ "node": ">=8" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", @@ -35371,6 +35581,27 @@ } } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xmlhttprequest-ssl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", diff --git a/packages/database/supabase/migrations/20260421120000_ballooning-tables.sql b/packages/database/supabase/migrations/20260421120000_ballooning-tables.sql new file mode 100644 index 000000000..6ed1dba07 --- /dev/null +++ b/packages/database/supabase/migrations/20260421120000_ballooning-tables.sql @@ -0,0 +1,391 @@ +-- Ballooning tables +-- - Uses "ballooningDrawing" as parent entity +-- - "ballooningBalloon" derives page from linked selector (no pageNumber column) +-- - Enforces tenant consistency with composite (id, companyId) foreign keys + +CREATE TABLE "ballooningDrawing" ( + "id" TEXT NOT NULL DEFAULT id('bdr'), + "companyId" TEXT NOT NULL, + "qualityDocumentId" TEXT NOT NULL, + "drawingNumber" TEXT, + "revision" TEXT, + "version" INTEGER NOT NULL DEFAULT 0, + "storagePath" TEXT NOT NULL, + "fileName" TEXT NOT NULL, + "pageCount" INTEGER, + "defaultPageWidth" DOUBLE PRECISION, + "defaultPageHeight" DOUBLE PRECISION, + "uploadedBy" TEXT NOT NULL, + "deletedAt" TIMESTAMP WITH TIME ZONE, + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + "updatedBy" TEXT, + "updatedAt" TIMESTAMP WITH TIME ZONE, + + CONSTRAINT "ballooningDrawing_pkey" PRIMARY KEY ("id", "companyId"), + CONSTRAINT "ballooningDrawing_id_unique" UNIQUE ("id"), + CONSTRAINT "ballooningDrawing_version_check" CHECK ("version" >= 0), + CONSTRAINT "ballooningDrawing_pageCount_check" CHECK ("pageCount" > 0), + CONSTRAINT "ballooningDrawing_defaultPageWidth_check" CHECK ("defaultPageWidth" > 0), + CONSTRAINT "ballooningDrawing_defaultPageHeight_check" CHECK ("defaultPageHeight" > 0), + + CONSTRAINT "ballooningDrawing_companyId_fkey" + FOREIGN KEY ("companyId") REFERENCES "company"("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "ballooningDrawing_qualityDocumentId_fkey" + FOREIGN KEY ("qualityDocumentId") REFERENCES "qualityDocument"("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "ballooningDrawing_uploadedBy_fkey" + FOREIGN KEY ("uploadedBy") REFERENCES "user"("id") ON UPDATE CASCADE, + CONSTRAINT "ballooningDrawing_createdBy_fkey" + FOREIGN KEY ("createdBy") REFERENCES "user"("id") ON UPDATE CASCADE, + CONSTRAINT "ballooningDrawing_updatedBy_fkey" + FOREIGN KEY ("updatedBy") REFERENCES "user"("id") ON UPDATE CASCADE +); + +CREATE TABLE "ballooningSelector" ( + "id" TEXT NOT NULL DEFAULT id('bsl'), + "drawingId" TEXT NOT NULL, + "companyId" TEXT NOT NULL, + "pageNumber" INTEGER NOT NULL, + "xCoordinate" DOUBLE PRECISION NOT NULL, + "yCoordinate" DOUBLE PRECISION NOT NULL, + "width" DOUBLE PRECISION NOT NULL, + "height" DOUBLE PRECISION NOT NULL, + "deletedAt" TIMESTAMP WITH TIME ZONE, + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + "updatedBy" TEXT, + "updatedAt" TIMESTAMP WITH TIME ZONE, + + CONSTRAINT "ballooningSelector_pkey" PRIMARY KEY ("id", "companyId"), + CONSTRAINT "ballooningSelector_id_unique" UNIQUE ("id"), + CONSTRAINT "ballooningSelector_pageNumber_check" CHECK ("pageNumber" > 0), + CONSTRAINT "ballooningSelector_xCoordinate_check" CHECK ("xCoordinate" >= 0 AND "xCoordinate" <= 1), + CONSTRAINT "ballooningSelector_yCoordinate_check" CHECK ("yCoordinate" >= 0 AND "yCoordinate" <= 1), + CONSTRAINT "ballooningSelector_width_check" CHECK ("width" > 0 AND "width" <= 1), + CONSTRAINT "ballooningSelector_height_check" CHECK ("height" > 0 AND "height" <= 1), + CONSTRAINT "ballooningSelector_xw_bounds_check" CHECK ("xCoordinate" + "width" <= 1), + CONSTRAINT "ballooningSelector_yh_bounds_check" CHECK ("yCoordinate" + "height" <= 1), + + CONSTRAINT "ballooningSelector_companyId_fkey" + FOREIGN KEY ("companyId") REFERENCES "company"("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "ballooningSelector_createdBy_fkey" + FOREIGN KEY ("createdBy") REFERENCES "user"("id") ON UPDATE CASCADE, + CONSTRAINT "ballooningSelector_updatedBy_fkey" + FOREIGN KEY ("updatedBy") REFERENCES "user"("id") ON UPDATE CASCADE, + CONSTRAINT "ballooningSelector_drawing_company_fkey" + FOREIGN KEY ("drawingId", "companyId") + REFERENCES "ballooningDrawing"("id", "companyId") + ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE TABLE "ballooningBalloon" ( + "id" TEXT NOT NULL DEFAULT id('bbn'), + "selectorId" TEXT NOT NULL, + "drawingId" TEXT NOT NULL, + "companyId" TEXT NOT NULL, + "label" TEXT NOT NULL, + "xCoordinate" DOUBLE PRECISION NOT NULL, + "yCoordinate" DOUBLE PRECISION NOT NULL, + "anchorX" DOUBLE PRECISION, + "anchorY" DOUBLE PRECISION, + "description" TEXT, + "data" JSONB, + "deletedAt" TIMESTAMP WITH TIME ZONE, + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + "updatedBy" TEXT, + "updatedAt" TIMESTAMP WITH TIME ZONE, + + CONSTRAINT "ballooningBalloon_pkey" PRIMARY KEY ("id", "companyId"), + CONSTRAINT "ballooningBalloon_id_unique" UNIQUE ("id"), + CONSTRAINT "ballooningBalloon_selectorId_unique" UNIQUE ("selectorId"), + CONSTRAINT "ballooningBalloon_xCoordinate_check" CHECK ("xCoordinate" >= 0 AND "xCoordinate" <= 1), + CONSTRAINT "ballooningBalloon_yCoordinate_check" CHECK ("yCoordinate" >= 0 AND "yCoordinate" <= 1), + CONSTRAINT "ballooningBalloon_anchorX_check" CHECK ("anchorX" IS NULL OR ("anchorX" >= 0 AND "anchorX" <= 1)), + CONSTRAINT "ballooningBalloon_anchorY_check" CHECK ("anchorY" IS NULL OR ("anchorY" >= 0 AND "anchorY" <= 1)), + + CONSTRAINT "ballooningBalloon_companyId_fkey" + FOREIGN KEY ("companyId") REFERENCES "company"("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "ballooningBalloon_createdBy_fkey" + FOREIGN KEY ("createdBy") REFERENCES "user"("id") ON UPDATE CASCADE, + CONSTRAINT "ballooningBalloon_updatedBy_fkey" + FOREIGN KEY ("updatedBy") REFERENCES "user"("id") ON UPDATE CASCADE, + CONSTRAINT "ballooningBalloon_drawing_company_fkey" + FOREIGN KEY ("drawingId", "companyId") + REFERENCES "ballooningDrawing"("id", "companyId") + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "ballooningBalloon_selector_company_fkey" + FOREIGN KEY ("selectorId", "companyId") + REFERENCES "ballooningSelector"("id", "companyId") + ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE TABLE "ballooningAnnotation" ( + "id" TEXT NOT NULL DEFAULT id('ban'), + "drawingId" TEXT NOT NULL, + "companyId" TEXT NOT NULL, + "pageNumber" INTEGER NOT NULL, + "xCoordinate" DOUBLE PRECISION NOT NULL, + "yCoordinate" DOUBLE PRECISION NOT NULL, + "text" TEXT NOT NULL, + "width" DOUBLE PRECISION, + "height" DOUBLE PRECISION, + "rotation" DOUBLE PRECISION NOT NULL DEFAULT 0, + "style" JSONB, + "deletedAt" TIMESTAMP WITH TIME ZONE, + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + "updatedBy" TEXT, + "updatedAt" TIMESTAMP WITH TIME ZONE, + + CONSTRAINT "ballooningAnnotation_pkey" PRIMARY KEY ("id", "companyId"), + CONSTRAINT "ballooningAnnotation_id_unique" UNIQUE ("id"), + CONSTRAINT "ballooningAnnotation_pageNumber_check" CHECK ("pageNumber" > 0), + CONSTRAINT "ballooningAnnotation_xCoordinate_check" CHECK ("xCoordinate" >= 0 AND "xCoordinate" <= 1), + CONSTRAINT "ballooningAnnotation_yCoordinate_check" CHECK ("yCoordinate" >= 0 AND "yCoordinate" <= 1), + CONSTRAINT "ballooningAnnotation_companyId_fkey" + FOREIGN KEY ("companyId") REFERENCES "company"("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "ballooningAnnotation_createdBy_fkey" + FOREIGN KEY ("createdBy") REFERENCES "user"("id") ON UPDATE CASCADE, + CONSTRAINT "ballooningAnnotation_updatedBy_fkey" + FOREIGN KEY ("updatedBy") REFERENCES "user"("id") ON UPDATE CASCADE, + CONSTRAINT "ballooningAnnotation_drawing_company_fkey" + FOREIGN KEY ("drawingId", "companyId") + REFERENCES "ballooningDrawing"("id", "companyId") + ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE INDEX "ballooningDrawing_companyId_idx" ON "ballooningDrawing" ("companyId"); +CREATE INDEX "ballooningDrawing_qualityDocumentId_idx" ON "ballooningDrawing" ("qualityDocumentId"); + +CREATE INDEX "ballooningSelector_companyId_idx" ON "ballooningSelector" ("companyId"); +CREATE INDEX "ballooningSelector_drawingId_idx" ON "ballooningSelector" ("drawingId"); +CREATE INDEX "ballooningSelector_drawing_page_idx" ON "ballooningSelector" ("drawingId", "companyId", "pageNumber"); +CREATE INDEX "ballooningSelector_active_page_idx" + ON "ballooningSelector" ("drawingId", "companyId", "pageNumber") + WHERE "deletedAt" IS NULL; + +CREATE INDEX "ballooningBalloon_companyId_idx" ON "ballooningBalloon" ("companyId"); +CREATE INDEX "ballooningBalloon_drawingId_idx" ON "ballooningBalloon" ("drawingId"); +CREATE INDEX "ballooningBalloon_selectorId_idx" ON "ballooningBalloon" ("selectorId"); +CREATE INDEX "ballooningBalloon_active_drawing_idx" + ON "ballooningBalloon" ("drawingId", "companyId") + WHERE "deletedAt" IS NULL; + +CREATE INDEX "ballooningAnnotation_companyId_idx" ON "ballooningAnnotation" ("companyId"); +CREATE INDEX "ballooningAnnotation_drawing_page_idx" ON "ballooningAnnotation" ("drawingId", "companyId", "pageNumber"); +CREATE INDEX "ballooningAnnotation_active_page_idx" + ON "ballooningAnnotation" ("drawingId", "companyId", "pageNumber") + WHERE "deletedAt" IS NULL; + +CREATE OR REPLACE FUNCTION enforce_unique_balloon_label_per_page() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + v_page_number INTEGER; + v_conflict_id TEXT; +BEGIN + SELECT s."pageNumber" + INTO v_page_number + FROM "ballooningSelector" s + WHERE s."id" = NEW."selectorId" + AND s."companyId" = NEW."companyId" + AND s."deletedAt" IS NULL; + + IF v_page_number IS NULL THEN + RAISE EXCEPTION 'selector % not found or deleted for company %', NEW."selectorId", NEW."companyId"; + END IF; + + SELECT b."id" + INTO v_conflict_id + FROM "ballooningBalloon" b + JOIN "ballooningSelector" s ON s."id" = b."selectorId" AND s."companyId" = b."companyId" + WHERE b."drawingId" = NEW."drawingId" + AND b."companyId" = NEW."companyId" + AND b."label" = NEW."label" + AND s."pageNumber" = v_page_number + AND b."deletedAt" IS NULL + AND s."deletedAt" IS NULL + AND b."id" <> COALESCE(NEW."id", '') + LIMIT 1; + + IF v_conflict_id IS NOT NULL THEN + RAISE EXCEPTION 'duplicate balloon label "%" on drawing "%" page %', NEW."label", NEW."drawingId", v_page_number; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER "trg_balloon_unique_label_per_page" +BEFORE INSERT OR UPDATE OF "selectorId", "drawingId", "label", "deletedAt" +ON "ballooningBalloon" +FOR EACH ROW +WHEN (NEW."deletedAt" IS NULL) +EXECUTE FUNCTION enforce_unique_balloon_label_per_page(); + +ALTER TABLE "ballooningDrawing" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "ballooningSelector" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "ballooningBalloon" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "ballooningAnnotation" ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "SELECT" ON "public"."ballooningDrawing" +FOR SELECT USING ( + "companyId" = ANY ( + ( + SELECT + get_companies_with_employee_role() + )::text[] + ) +); + +CREATE POLICY "INSERT" ON "public"."ballooningDrawing" +FOR INSERT WITH CHECK ( + "companyId" = ANY ( + ( + SELECT + get_companies_with_employee_permission('quality_create') + )::text[] + ) +); + +CREATE POLICY "UPDATE" ON "public"."ballooningDrawing" +FOR UPDATE USING ( + "companyId" = ANY ( + ( + SELECT + get_companies_with_employee_permission('quality_update') + )::text[] + ) +); + +CREATE POLICY "DELETE" ON "public"."ballooningDrawing" +FOR DELETE USING ( + "companyId" = ANY ( + ( + SELECT + get_companies_with_employee_permission('quality_delete') + )::text[] + ) +); + +CREATE POLICY "SELECT" ON "public"."ballooningSelector" +FOR SELECT USING ( + "companyId" = ANY ( + ( + SELECT + get_companies_with_employee_role() + )::text[] + ) +); + +CREATE POLICY "INSERT" ON "public"."ballooningSelector" +FOR INSERT WITH CHECK ( + "companyId" = ANY ( + ( + SELECT + get_companies_with_employee_permission('quality_create') + )::text[] + ) +); + +CREATE POLICY "UPDATE" ON "public"."ballooningSelector" +FOR UPDATE USING ( + "companyId" = ANY ( + ( + SELECT + get_companies_with_employee_permission('quality_update') + )::text[] + ) +); + +CREATE POLICY "DELETE" ON "public"."ballooningSelector" +FOR DELETE USING ( + "companyId" = ANY ( + ( + SELECT + get_companies_with_employee_permission('quality_delete') + )::text[] + ) +); + +CREATE POLICY "SELECT" ON "public"."ballooningBalloon" +FOR SELECT USING ( + "companyId" = ANY ( + ( + SELECT + get_companies_with_employee_role() + )::text[] + ) +); + +CREATE POLICY "INSERT" ON "public"."ballooningBalloon" +FOR INSERT WITH CHECK ( + "companyId" = ANY ( + ( + SELECT + get_companies_with_employee_permission('quality_create') + )::text[] + ) +); + +CREATE POLICY "UPDATE" ON "public"."ballooningBalloon" +FOR UPDATE USING ( + "companyId" = ANY ( + ( + SELECT + get_companies_with_employee_permission('quality_update') + )::text[] + ) +); + +CREATE POLICY "DELETE" ON "public"."ballooningBalloon" +FOR DELETE USING ( + "companyId" = ANY ( + ( + SELECT + get_companies_with_employee_permission('quality_delete') + )::text[] + ) +); + +CREATE POLICY "SELECT" ON "public"."ballooningAnnotation" +FOR SELECT USING ( + "companyId" = ANY ( + ( + SELECT + get_companies_with_employee_role() + )::text[] + ) +); + +CREATE POLICY "INSERT" ON "public"."ballooningAnnotation" +FOR INSERT WITH CHECK ( + "companyId" = ANY ( + ( + SELECT + get_companies_with_employee_permission('quality_create') + )::text[] + ) +); + +CREATE POLICY "UPDATE" ON "public"."ballooningAnnotation" +FOR UPDATE USING ( + "companyId" = ANY ( + ( + SELECT + get_companies_with_employee_permission('quality_update') + )::text[] + ) +); + +CREATE POLICY "DELETE" ON "public"."ballooningAnnotation" +FOR DELETE USING ( + "companyId" = ANY ( + ( + SELECT + get_companies_with_employee_permission('quality_delete') + )::text[] + ) +); diff --git a/packages/react/src/Editor/Editor.tsx b/packages/react/src/Editor/Editor.tsx index 71a788531..c1ccb97d1 100644 --- a/packages/react/src/Editor/Editor.tsx +++ b/packages/react/src/Editor/Editor.tsx @@ -19,16 +19,20 @@ import { renderItems } from "@carbon/tiptap"; import TextStyle from "@tiptap/extension-text-style"; -import { useMemo, useRef, useState } from "react"; +import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { Separator } from "../Separator"; import { cn } from "../utils/cn"; import { ColorSelector } from "./components/ColorSelector"; import { LinkSelector } from "./components/LinkSelector"; -import { NodeSelector } from "./components/NodeSelector"; -import { TextButtons } from "./components/TextButton"; import { defaultExtensions } from "./extensions"; -import { getSuggestionItems } from "./slash"; + +const NodeSelector = lazy(() => + import("./components/NodeSelector").then((m) => ({ default: m.NodeSelector })) +); +const TextButtons = lazy(() => + import("./components/TextButton").then((m) => ({ default: m.TextButtons })) +); interface MentionConfig { /** @@ -76,6 +80,7 @@ const Editor = ({ const [openNode, setOpenNode] = useState(false); const [openColor, setOpenColor] = useState(false); const [openLink, setOpenLink] = useState(false); + const [suggestionItems, setSuggestionItems] = useState([]); // Use a ref to hold the current mention items so the extension can access // the latest items without needing to be recreated @@ -105,10 +110,16 @@ const Editor = ({ }); }, [onUpload, disableFileUpload]); - const suggestionItems = useMemo( - () => getSuggestionItems(uploadFn), - [uploadFn] - ); + useEffect(() => { + let cancelled = false; + import("./slash").then((m) => { + if (cancelled) return; + setSuggestionItems(m.getSuggestionItems(uploadFn)); + }); + return () => { + cancelled = true; + }; + }, [uploadFn]); // biome-ignore lint/correctness/useExhaustiveDependencies: suppressed due to migration const mentionExtension = useMemo(() => { @@ -195,12 +206,16 @@ const Editor = ({ className="flex w-fit max-w-[90vw] overflow-hidden rounded-md border border-muted bg-background shadow-xl p-2" > - + + + - + + + From e116e5e7b9cdd0dc6cf4076ca19456cff3b802a1 Mon Sep 17 00:00:00 2001 From: Atharv Date: Wed, 22 Apr 2026 19:32:04 +0530 Subject: [PATCH 2/2] Enhanced the structure of code & added some features --- .../components/Layout/Topbar/Breadcrumbs.tsx | 5 +- .../erp/app/modules/quality/quality.models.ts | 78 +- .../app/modules/quality/quality.service.ts | 504 +++- apps/erp/app/modules/quality/types.ts | 67 +- .../BalloonDocumentEditor.tsx} | 2363 +++++++++++------ .../BalloonDocumentForm.tsx} | 20 +- .../BalloonDocument/BalloonDocumentTable.tsx | 159 ++ .../exportBalloonDocumentPdfWithOverlays.ts} | 2 +- .../quality/ui/BalloonDocument/index.ts | 8 + .../quality/ui/Ballooning/BallooningTable.tsx | 157 -- .../modules/quality/ui/Ballooning/index.ts | 8 - .../quality/ui/useQualitySubmodules.tsx | 4 +- apps/erp/app/routes/api+/mcp+/lib/server.ts | 2 +- .../app/routes/api+/mcp+/lib/tools/quality.ts | 265 +- .../routes/x+/balloon+/$id.anchor.create.tsx | 90 + .../routes/x+/balloon+/$id.anchor.delete.tsx | 73 + .../app/routes/x+/balloon+/$id.anchor.get.tsx | 43 + .../routes/x+/balloon+/$id.anchor.update.tsx | 87 + .../x+/balloon+/$id.annotation.create.tsx | 89 + .../x+/balloon+/$id.annotation.delete.tsx | 70 + .../routes/x+/balloon+/$id.annotation.get.tsx | 40 + .../x+/balloon+/$id.annotation.update.tsx | 87 + .../routes/x+/balloon+/$id.balloon.create.tsx | 108 + .../routes/x+/balloon+/$id.balloon.delete.tsx | 70 + .../routes/x+/balloon+/$id.balloon.get.tsx | 40 + .../routes/x+/balloon+/$id.balloon.update.tsx | 86 + .../$id.delete.tsx} | 12 +- .../$id.save.tsx | 67 +- .../{ballooning-diagram+ => balloon+}/$id.tsx | 50 +- .../ballooning.tsx => balloon+/_layout.tsx} | 26 +- .../ballooning.new.tsx => balloon+/new.tsx} | 22 +- .../routes/x+/ballooning-diagram+/_layout.tsx | 19 - apps/erp/app/utils/path.ts | 13 +- llm/cache/mcp-tools-reference.md | 139 +- ...0260421120000_balloon-document-tables.sql} | 194 +- 35 files changed, 3629 insertions(+), 1438 deletions(-) rename apps/erp/app/modules/quality/ui/{Ballooning/BalloonDiagramEditor.tsx => BalloonDocument/BalloonDocumentEditor.tsx} (50%) rename apps/erp/app/modules/quality/ui/{Ballooning/BallooningForm.tsx => BalloonDocument/BalloonDocumentForm.tsx} (87%) create mode 100644 apps/erp/app/modules/quality/ui/BalloonDocument/BalloonDocumentTable.tsx rename apps/erp/app/modules/quality/ui/{Ballooning/exportBallooningPdfWithOverlays.ts => BalloonDocument/exportBalloonDocumentPdfWithOverlays.ts} (98%) create mode 100644 apps/erp/app/modules/quality/ui/BalloonDocument/index.ts delete mode 100644 apps/erp/app/modules/quality/ui/Ballooning/BallooningTable.tsx delete mode 100644 apps/erp/app/modules/quality/ui/Ballooning/index.ts create mode 100644 apps/erp/app/routes/x+/balloon+/$id.anchor.create.tsx create mode 100644 apps/erp/app/routes/x+/balloon+/$id.anchor.delete.tsx create mode 100644 apps/erp/app/routes/x+/balloon+/$id.anchor.get.tsx create mode 100644 apps/erp/app/routes/x+/balloon+/$id.anchor.update.tsx create mode 100644 apps/erp/app/routes/x+/balloon+/$id.annotation.create.tsx create mode 100644 apps/erp/app/routes/x+/balloon+/$id.annotation.delete.tsx create mode 100644 apps/erp/app/routes/x+/balloon+/$id.annotation.get.tsx create mode 100644 apps/erp/app/routes/x+/balloon+/$id.annotation.update.tsx create mode 100644 apps/erp/app/routes/x+/balloon+/$id.balloon.create.tsx create mode 100644 apps/erp/app/routes/x+/balloon+/$id.balloon.delete.tsx create mode 100644 apps/erp/app/routes/x+/balloon+/$id.balloon.get.tsx create mode 100644 apps/erp/app/routes/x+/balloon+/$id.balloon.update.tsx rename apps/erp/app/routes/x+/{ballooning-diagram+/delete.$id.tsx => balloon+/$id.delete.tsx} (68%) rename apps/erp/app/routes/x+/{ballooning-diagram+ => balloon+}/$id.save.tsx (89%) rename apps/erp/app/routes/x+/{ballooning-diagram+ => balloon+}/$id.tsx (67%) rename apps/erp/app/routes/x+/{quality+/ballooning.tsx => balloon+/_layout.tsx} (59%) rename apps/erp/app/routes/x+/{quality+/ballooning.new.tsx => balloon+/new.tsx} (67%) delete mode 100644 apps/erp/app/routes/x+/ballooning-diagram+/_layout.tsx rename packages/database/supabase/migrations/{20260421120000_ballooning-tables.sql => 20260421120000_balloon-document-tables.sql} (54%) diff --git a/apps/erp/app/components/Layout/Topbar/Breadcrumbs.tsx b/apps/erp/app/components/Layout/Topbar/Breadcrumbs.tsx index d2b0e6478..c8a5a4d58 100644 --- a/apps/erp/app/components/Layout/Topbar/Breadcrumbs.tsx +++ b/apps/erp/app/components/Layout/Topbar/Breadcrumbs.tsx @@ -76,7 +76,10 @@ const Breadcrumbs = () => { return { breadcrumb: translateBreadcrumb( typeof result.data.handle.breadcrumb === "function" - ? result.data.handle.breadcrumb(m.params) + ? result.data.handle.breadcrumb( + m.params, + (m as { data?: unknown }).data + ) : result.data.handle.breadcrumb ), to: result.data.handle?.to ?? m.pathname diff --git a/apps/erp/app/modules/quality/quality.models.ts b/apps/erp/app/modules/quality/quality.models.ts index 5c0427f0f..7c52b43f8 100644 --- a/apps/erp/app/modules/quality/quality.models.ts +++ b/apps/erp/app/modules/quality/quality.models.ts @@ -94,7 +94,7 @@ export const balloonCharacteristicType = [ "Reference" ] as const; -export const ballooningDiagramValidator = z.object({ +export const balloonDocumentValidator = z.object({ id: zfd.text(z.string().optional()), name: z.string().min(1, { message: "Name is required" }), drawingNumber: zfd.text(z.string().optional()), @@ -106,7 +106,7 @@ export const ballooningDiagramValidator = z.object({ export const balloonFeatureValidator = z.object({ id: zfd.text(z.string().optional()), - ballooningDiagramId: z.string().min(1, { message: "Diagram is required" }), + balloonDocumentId: z.string().min(1, { message: "Diagram is required" }), balloonNumber: zfd.numeric(z.number().min(1)), description: z.string().min(1, { message: "Description is required" }), nominalValue: zfd.numeric(z.number().optional()), @@ -117,6 +117,80 @@ export const balloonFeatureValidator = z.object({ sortOrder: zfd.numeric(z.number().optional()) }); +export const balloonAnchorCreateItemValidator = z.object({ + pageNumber: z.number(), + xCoordinate: z.number(), + yCoordinate: z.number(), + width: z.number(), + height: z.number() +}); + +export const balloonAnchorUpdateItemValidator = z.object({ + id: z.string().min(1), + pageNumber: z.number().optional(), + xCoordinate: z.number().optional(), + yCoordinate: z.number().optional(), + width: z.number().optional(), + height: z.number().optional() +}); + +export const balloonAnchorDeleteValidator = z.object({ + ids: z.array(z.string().min(1)) +}); + +export const balloonCreateFromPayloadItemValidator = z.object({ + tempSelectorId: z.string().min(1), + label: z.string().min(1), + xCoordinate: z.number(), + yCoordinate: z.number(), + anchorX: z.number(), + anchorY: z.number(), + data: z.record(z.unknown()), + description: z.string().nullable().optional() +}); + +export const balloonUpdateItemValidator = z.object({ + id: z.string().min(1), + label: z.string().optional(), + xCoordinate: z.number().optional(), + yCoordinate: z.number().optional(), + anchorX: z.number().optional(), + anchorY: z.number().optional(), + data: z.record(z.unknown()).optional(), + description: z.string().nullable().optional() +}); + +export const balloonDeleteValidator = z.object({ + ids: z.array(z.string().min(1)) +}); + +export const balloonAnnotationCreateItemValidator = z.object({ + pageNumber: z.number().int().min(1), + xCoordinate: z.number(), + yCoordinate: z.number(), + text: z.string().min(1), + width: z.number().optional(), + height: z.number().optional(), + rotation: z.number().optional(), + style: z.record(z.unknown()).optional() +}); + +export const balloonAnnotationUpdateItemValidator = z.object({ + id: z.string().min(1), + pageNumber: z.number().int().min(1).optional(), + xCoordinate: z.number().optional(), + yCoordinate: z.number().optional(), + text: z.string().min(1).optional(), + width: z.number().nullable().optional(), + height: z.number().nullable().optional(), + rotation: z.number().optional(), + style: z.record(z.unknown()).nullable().optional() +}); + +export const balloonAnnotationDeleteValidator = z.object({ + ids: z.array(z.string().min(1)) +}); + export const gaugeValidator = z.object({ id: zfd.text(z.string().optional()), gaugeId: zfd.text(z.string().optional()), diff --git a/apps/erp/app/modules/quality/quality.service.ts b/apps/erp/app/modules/quality/quality.service.ts index 854a902ff..ce14e9a49 100644 --- a/apps/erp/app/modules/quality/quality.service.ts +++ b/apps/erp/app/modules/quality/quality.service.ts @@ -10,7 +10,16 @@ import { sanitize } from "~/utils/supabase"; import type { inspectionStatus } from "../shared"; import type { - ballooningDiagramValidator, + balloonAnchorCreateItemValidator, + balloonAnchorDeleteValidator, + balloonAnchorUpdateItemValidator, + balloonAnnotationCreateItemValidator, + balloonAnnotationDeleteValidator, + balloonAnnotationUpdateItemValidator, + balloonCreateFromPayloadItemValidator, + balloonDeleteValidator, + balloonDocumentValidator, + balloonUpdateItemValidator, gaugeCalibrationRecordValidator, gaugeCalibrationStatus, gaugeTypeValidator, @@ -1770,9 +1779,9 @@ export async function upsertRisk( } } -// ─── Ballooning Diagrams ───────────────────────────────────────────────────── -// Stored in ballooningDrawing -// This first step intentionally de-links ballooning diagrams from qualityDocument content. +// ─── Balloon Documents ─────────────────────────────────────────────────────── +// Stored in balloonDocument +// This first step intentionally de-links balloon documents from qualityDocument content. function toStoragePath(pdfUrl?: string | null) { if (!pdfUrl) return null; @@ -1795,7 +1804,7 @@ function fileNameFromPath(storagePath?: string | null) { return storagePath.split("/").at(-1) ?? "drawing.pdf"; } -function mapBallooningDrawingToDiagram(row: Record) { +function mapBalloonDocument(row: Record) { const drawingNumber = (row.drawingNumber as string | null) ?? null; return { id: String(row.id), @@ -1816,7 +1825,7 @@ function mapBallooningDrawingToDiagram(row: Record) { }; } -export async function getBallooningDiagrams( +export async function getBalloonDocuments( client: SupabaseClient, companyId: string, args?: { search: string | null } & GenericQueryFilters @@ -1825,55 +1834,41 @@ export async function getBallooningDiagrams( from: (table: string) => { select: ( columns: string, - options?: { count?: "exact" } - ) => { - eq: ( - column: string, - value: unknown - ) => { - is: ( - column: string, - value: null - ) => { - or: (filter: string) => Promise<{ - data: Record[] | null; - count: number | null; - error: unknown; - }>; - order: ( - column: string, - opts: { ascending: boolean } - ) => Promise<{ - data: Record[] | null; - count: number | null; - error: unknown; - }>; - }; - }; - }; + options?: { count?: "exact" | "planned" | "estimated"; head?: boolean } + ) => any; }; }; let query = drawingClient - .from("ballooningDrawing") + .from("balloonDocument") .select("*", { count: "exact" }) .eq("companyId", companyId) .is("deletedAt", null); - const result = args?.search - ? await query.or( - `drawingNumber.ilike.%${args.search}%,fileName.ilike.%${args.search}%` - ) - : await query.order("drawingNumber", { ascending: true }); + if (args?.search) { + query = query.or( + `drawingNumber.ilike.%${args.search}%,fileName.ilike.%${args.search}%` + ); + } + + if (args) { + query = setGenericQueryFilters(query, args, [ + { column: "drawingNumber", ascending: true } + ]); + } + + const result = await query; return { - data: (result.data ?? []).map(mapBallooningDrawingToDiagram), + data: (result.data ?? []).map((row: Record) => + mapBalloonDocument(row) + ), count: result.count ?? 0, error: result.error }; } -export async function getBallooningDiagram( +export async function getBalloonDocument( client: SupabaseClient, id: string ) { @@ -1899,30 +1894,31 @@ export async function getBallooningDiagram( }; const result = await drawingClient - .from("ballooningDrawing") + .from("balloonDocument") .select("*") .eq("id", id) .is("deletedAt", null) .single(); return { - data: result.data ? mapBallooningDrawingToDiagram(result.data) : null, + data: result.data ? mapBalloonDocument(result.data) : null, error: result.error }; } -export async function upsertBallooningDiagram( +export async function upsertBalloonDocument( client: SupabaseClient, diagram: - | (Omit, "id"> & { + | (Omit, "id"> & { id?: undefined; companyId: string; createdBy: string; + updatedBy?: string; pageCount?: number; defaultPageWidth?: number; defaultPageHeight?: number; }) - | (Omit, "id"> & { + | (Omit, "id"> & { id: string; companyId?: string; createdBy: string; @@ -1948,6 +1944,22 @@ export async function upsertBallooningDiagram( const drawingClient = client as unknown as { from: (table: string) => { + select: (columns: string) => { + eq: ( + column: string, + value: unknown + ) => { + is: ( + column: string, + value: null + ) => { + single: () => Promise<{ + data: Record | null; + error: unknown; + }>; + }; + }; + }; update: (payload: Record) => { eq: ( column: string, @@ -1976,7 +1988,7 @@ export async function upsertBallooningDiagram( if (id) { const existingResult = await drawingClient - .from("ballooningDrawing") + .from("balloonDocument") .select("*") .eq("id", id) .is("deletedAt", null) @@ -1987,7 +1999,7 @@ export async function upsertBallooningDiagram( return { data: null, error: { - message: "Ballooning drawing not found" + message: "Balloon document not found" } }; } @@ -2023,7 +2035,7 @@ export async function upsertBallooningDiagram( } return drawingClient - .from("ballooningDrawing") + .from("balloonDocument") .update(updatePayload) .eq("id", id) .select("id") @@ -2033,14 +2045,14 @@ export async function upsertBallooningDiagram( if (!companyId) { return { data: null, - error: { message: "companyId is required to create ballooning drawing" } + error: { message: "companyId is required to create balloon document" } }; } if (!storagePath) { return { data: null, - error: { message: "PDF upload is required to create ballooning diagram" } + error: { message: "PDF upload is required to create balloon document" } }; } @@ -2050,7 +2062,7 @@ export async function upsertBallooningDiagram( name, companyId, createdBy, - tags: ["ballooning"], + tags: ["balloonDocument"], status: "Active", content: {} }) @@ -2067,7 +2079,7 @@ export async function upsertBallooningDiagram( } return drawingClient - .from("ballooningDrawing") + .from("balloonDocument") .insert({ companyId, qualityDocumentId: qualityDocumentInsert.data.id, @@ -2088,12 +2100,28 @@ export async function upsertBallooningDiagram( .single(); } -export async function deleteBallooningDiagram( +export async function deleteBalloonDocument( client: SupabaseClient, id: string ) { const drawingClient = client as unknown as { from: (table: string) => { + select: (columns: string) => { + eq: ( + column: string, + value: unknown + ) => { + is: ( + column: string, + value: null + ) => { + single: () => Promise<{ + data: Record | null; + error: unknown; + }>; + }; + }; + }; update: (payload: Record) => { eq: ( column: string, @@ -2106,7 +2134,7 @@ export async function deleteBallooningDiagram( }; const existingResult = await drawingClient - .from("ballooningDrawing") + .from("balloonDocument") .select("*") .eq("id", id) .is("deletedAt", null) @@ -2114,7 +2142,7 @@ export async function deleteBallooningDiagram( if (!existingResult.data) { return { - error: { message: "Ballooning drawing not found" } + error: { message: "Balloon document not found" } }; } @@ -2127,12 +2155,12 @@ export async function deleteBallooningDiagram( .eq("id", String(existingResult.data.qualityDocumentId)); return drawingClient - .from("ballooningDrawing") + .from("balloonDocument") .update({ deletedAt: new Date().toISOString() }) .eq("id", id); } -export async function getBallooningSelectors( +export async function getBalloonAnchors( client: SupabaseClient, drawingId: string ) { @@ -2161,26 +2189,20 @@ export async function getBallooningSelectors( }; return drawingClient - .from("ballooningSelector") + .from("balloonAnchor") .select("*") .eq("drawingId", drawingId) .is("deletedAt", null) .order("createdAt", { ascending: true }); } -export async function createBallooningSelectors( +export async function createBalloonAnchors( client: SupabaseClient, args: { drawingId: string; companyId: string; createdBy: string; - selectors: { - pageNumber: number; - xCoordinate: number; - yCoordinate: number; - width: number; - height: number; - }[]; + selectors: z.infer[]; } ) { const drawingClient = client as unknown as { @@ -2199,7 +2221,7 @@ export async function createBallooningSelectors( } return drawingClient - .from("ballooningSelector") + .from("balloonAnchor") .insert( args.selectors.map((s) => ({ drawingId: args.drawingId, @@ -2216,20 +2238,13 @@ export async function createBallooningSelectors( .select("*"); } -export async function updateBallooningSelectors( +export async function updateBalloonAnchors( client: SupabaseClient, args: { drawingId: string; companyId: string; updatedBy: string; - selectors: { - id: string; - pageNumber?: number; - xCoordinate?: number; - yCoordinate?: number; - width?: number; - height?: number; - }[]; + selectors: z.infer[]; } ) { const drawingClient = client as unknown as { @@ -2239,10 +2254,20 @@ export async function updateBallooningSelectors( column: string, value: unknown ) => { - select: (columns: string) => Promise<{ - data: Record[] | null; - error: unknown; - }>; + eq: ( + column: string, + value: unknown + ) => { + eq: ( + column: string, + value: unknown + ) => { + select: (columns: string) => Promise<{ + data: Record[] | null; + error: unknown; + }>; + }; + }; }; }; }; @@ -2275,7 +2300,7 @@ export async function updateBallooningSelectors( } const result = await drawingClient - .from("ballooningSelector") + .from("balloonAnchor") .update(payload) .eq("id", selector.id) .eq("drawingId", args.drawingId) @@ -2293,13 +2318,13 @@ export async function updateBallooningSelectors( return { data: updated, error: null }; } -export async function deleteBallooningSelectors( +export async function deleteBalloonAnchors( client: SupabaseClient, args: { drawingId: string; companyId: string; updatedBy: string; - ids: string[]; + ids: z.infer["ids"]; } ) { const drawingClient = client as unknown as { @@ -2320,10 +2345,12 @@ export async function deleteBallooningSelectors( is: ( column: string, value: null - ) => Promise<{ - data: Record[] | null; - error: unknown; - }>; + ) => { + select: (columns: string) => Promise<{ + data: Record[] | null; + error: unknown; + }>; + }; }; }; }; @@ -2336,7 +2363,7 @@ export async function deleteBallooningSelectors( } return drawingClient - .from("ballooningSelector") + .from("balloonAnchor") .update({ deletedAt: new Date().toISOString(), updatedBy: args.updatedBy, @@ -2386,7 +2413,7 @@ function clampRectToBounds(rect: BalloonRect): BalloonRect { return { ...rect, x, y }; } -export async function getBallooningBalloons( +export async function getBalloons( client: SupabaseClient, drawingId: string ) { @@ -2418,14 +2445,14 @@ export async function getBallooningBalloons( }; return drawingClient - .from("ballooningBalloon") + .from("balloon") .select("*") .eq("drawingId", drawingId) .is("deletedAt", null) .order("createdAt", { ascending: true }); } -export async function createBalloonsForSelectors( +export async function createBalloonsForAnchors( client: SupabaseClient, args: { drawingId: string; @@ -2473,7 +2500,7 @@ export async function createBalloonsForSelectors( } const existing = await drawingClient - .from("ballooningBalloon") + .from("balloon") .select("id, xCoordinate, yCoordinate, data", { count: "exact" }) .eq("drawingId", args.drawingId) .is("deletedAt", null); @@ -2509,7 +2536,7 @@ export async function createBalloonsForSelectors( }) .filter((r) => Number.isFinite(r.x) && Number.isFinite(r.y)); - return drawingClient.from("ballooningBalloon").insert( + return drawingClient.from("balloon").insert( args.selectors.map((s) => { const anchorX = clamp01(s.xCoordinate + s.width / 2); const anchorY = clamp01(s.yCoordinate + s.height / 2); @@ -2599,23 +2626,14 @@ export async function createBalloonsForSelectors( ); } -export async function createBallooningBalloonsFromPayload( +export async function createBalloonsFromPayload( client: SupabaseClient, args: { drawingId: string; companyId: string; createdBy: string; selectorIdMap: Record; - balloons: Array<{ - tempSelectorId: string; - label: string; - xCoordinate: number; - yCoordinate: number; - anchorX: number; - anchorY: number; - data: Record; - description?: string | null; - }>; + balloons: z.infer[]; } ) { const drawingClient = client as unknown as { @@ -2660,26 +2678,17 @@ export async function createBallooningBalloonsFromPayload( } return drawingClient - .from("ballooningBalloon") + .from("balloon") .insert(rows as Record[]); } -export async function updateBallooningBalloons( +export async function updateBalloons( client: SupabaseClient, args: { drawingId: string; companyId: string; updatedBy: string; - balloons: Array<{ - id: string; - label?: string; - xCoordinate?: number; - yCoordinate?: number; - anchorX?: number; - anchorY?: number; - data?: Record; - description?: string | null; - }>; + balloons: z.infer[]; } ) { const drawingClient = client as unknown as { @@ -2700,10 +2709,12 @@ export async function updateBallooningBalloons( is: ( column: string, value: null - ) => Promise<{ - data: Record[] | null; - error: unknown; - }>; + ) => { + select: (columns: string) => Promise<{ + data: Record[] | null; + error: unknown; + }>; + }; }; }; }; @@ -2730,7 +2741,7 @@ export async function updateBallooningBalloons( if (b.description !== undefined) payload.description = b.description; const result = await drawingClient - .from("ballooningBalloon") + .from("balloon") .update(payload) .eq("id", b.id) .eq("drawingId", args.drawingId) @@ -2749,13 +2760,13 @@ export async function updateBallooningBalloons( return { data: updated, error: null }; } -export async function deleteBallooningBalloons( +export async function deleteBalloons( client: SupabaseClient, args: { drawingId: string; companyId: string; updatedBy: string; - ids: string[]; + ids: z.infer["ids"]; } ) { const drawingClient = client as unknown as { @@ -2776,10 +2787,247 @@ export async function deleteBallooningBalloons( is: ( column: string, value: null - ) => Promise<{ - data: Record[] | null; - error: unknown; - }>; + ) => { + select: (columns: string) => Promise<{ + data: Record[] | null; + error: unknown; + }>; + }; + }; + }; + }; + }; + }; + }; + + if (args.ids.length === 0) { + return { data: [], error: null }; + } + + return drawingClient + .from("balloon") + .update({ + deletedAt: new Date().toISOString(), + updatedBy: args.updatedBy, + updatedAt: new Date().toISOString() + }) + .in("id", args.ids) + .eq("drawingId", args.drawingId) + .eq("companyId", args.companyId) + .is("deletedAt", null) + .select("id"); +} + +export async function getBalloonAnnotations( + client: SupabaseClient, + drawingId: string +) { + const drawingClient = client as unknown as { + from: (table: string) => { + select: (columns: string) => { + eq: ( + column: string, + value: unknown + ) => { + is: ( + column: string, + value: null + ) => { + order: ( + column: string, + opts: { ascending: boolean } + ) => Promise<{ + data: Record[] | null; + error: unknown; + }>; + }; + }; + }; + }; + }; + + return drawingClient + .from("balloonAnnotation") + .select("*") + .eq("drawingId", drawingId) + .is("deletedAt", null) + .order("createdAt", { ascending: true }); +} + +export async function createBalloonAnnotations( + client: SupabaseClient, + args: { + drawingId: string; + companyId: string; + createdBy: string; + annotations: z.infer[]; + } +) { + const drawingClient = client as unknown as { + from: (table: string) => { + insert: (payload: Record[]) => { + select: (columns: string) => Promise<{ + data: Record[] | null; + error: unknown; + }>; + }; + }; + }; + + if (args.annotations.length === 0) { + return { data: [], error: null }; + } + + return drawingClient + .from("balloonAnnotation") + .insert( + args.annotations.map((annotation) => ({ + drawingId: args.drawingId, + companyId: args.companyId, + pageNumber: annotation.pageNumber, + xCoordinate: annotation.xCoordinate, + yCoordinate: annotation.yCoordinate, + text: annotation.text, + width: annotation.width ?? null, + height: annotation.height ?? null, + rotation: annotation.rotation ?? 0, + style: annotation.style ?? null, + createdBy: args.createdBy, + updatedBy: args.createdBy + })) + ) + .select("*"); +} + +export async function updateBalloonAnnotations( + client: SupabaseClient, + args: { + drawingId: string; + companyId: string; + updatedBy: string; + annotations: z.infer[]; + } +) { + const drawingClient = client as unknown as { + from: (table: string) => { + update: (payload: Record) => { + eq: ( + column: string, + value: unknown + ) => { + eq: ( + column: string, + value: unknown + ) => { + eq: ( + column: string, + value: unknown + ) => { + is: ( + column: string, + value: null + ) => { + select: (columns: string) => Promise<{ + data: Record[] | null; + error: unknown; + }>; + }; + }; + }; + }; + }; + }; + }; + + if (args.annotations.length === 0) { + return { data: [], error: null }; + } + + const updated: Record[] = []; + for (const annotation of args.annotations) { + const payload: Record = { + updatedBy: args.updatedBy, + updatedAt: new Date().toISOString() + }; + + if (typeof annotation.pageNumber === "number") { + payload.pageNumber = annotation.pageNumber; + } + if (typeof annotation.xCoordinate === "number") { + payload.xCoordinate = annotation.xCoordinate; + } + if (typeof annotation.yCoordinate === "number") { + payload.yCoordinate = annotation.yCoordinate; + } + if (typeof annotation.text === "string") { + payload.text = annotation.text; + } + if (annotation.width !== undefined) { + payload.width = annotation.width; + } + if (annotation.height !== undefined) { + payload.height = annotation.height; + } + if (typeof annotation.rotation === "number") { + payload.rotation = annotation.rotation; + } + if (annotation.style !== undefined) { + payload.style = annotation.style; + } + + const result = await drawingClient + .from("balloonAnnotation") + .update(payload) + .eq("id", annotation.id) + .eq("drawingId", args.drawingId) + .eq("companyId", args.companyId) + .is("deletedAt", null) + .select("*"); + + if (result.error) { + return { data: null, error: result.error }; + } + if (result.data?.[0]) { + updated.push(result.data[0]); + } + } + + return { data: updated, error: null }; +} + +export async function deleteBalloonAnnotations( + client: SupabaseClient, + args: { + drawingId: string; + companyId: string; + updatedBy: string; + ids: z.infer["ids"]; + } +) { + const drawingClient = client as unknown as { + from: (table: string) => { + update: (payload: Record) => { + in: ( + column: string, + values: string[] + ) => { + eq: ( + column: string, + value: unknown + ) => { + eq: ( + column: string, + value: unknown + ) => { + is: ( + column: string, + value: null + ) => { + select: (columns: string) => Promise<{ + data: Record[] | null; + error: unknown; + }>; + }; }; }; }; @@ -2792,7 +3040,7 @@ export async function deleteBallooningBalloons( } return drawingClient - .from("ballooningBalloon") + .from("balloonAnnotation") .update({ deletedAt: new Date().toISOString(), updatedBy: args.updatedBy, diff --git a/apps/erp/app/modules/quality/types.ts b/apps/erp/app/modules/quality/types.ts index d176f5a4a..f704cbb3e 100644 --- a/apps/erp/app/modules/quality/types.ts +++ b/apps/erp/app/modules/quality/types.ts @@ -1,8 +1,23 @@ import type { Database } from "@carbon/database"; -import type { nonConformanceAssociationType } from "./quality.models"; +import type { z } from "zod"; import type { - getBallooningDiagram, - getBallooningDiagrams, + balloonAnchorCreateItemValidator, + balloonAnchorDeleteValidator, + balloonAnchorUpdateItemValidator, + balloonAnnotationCreateItemValidator, + balloonAnnotationDeleteValidator, + balloonAnnotationUpdateItemValidator, + balloonCreateFromPayloadItemValidator, + balloonDeleteValidator, + balloonUpdateItemValidator, + nonConformanceAssociationType +} from "./quality.models"; +import type { + getBalloonAnchors, + getBalloonAnnotations, + getBalloonDocument, + getBalloonDocuments, + getBalloons, getGaugeCalibrationRecords, getGauges, getGaugeTypes, @@ -23,14 +38,25 @@ import type { getRisks } from "./quality.service"; -export type BallooningDiagram = NonNullable< - Awaited>["data"] +export type BalloonDocument = NonNullable< + Awaited>["data"] >[number]; -export type BallooningDiagramDetail = NonNullable< - Awaited>["data"] +export type BalloonDocumentDetail = NonNullable< + Awaited>["data"] >; +export type BalloonAnchor = NonNullable< + Awaited>["data"] +>[number]; + +export type Balloon = NonNullable< + Awaited>["data"] +>[number]; +export type BalloonAnnotationRecord = NonNullable< + Awaited>["data"] +>[number]; + export type BalloonAnnotation = { id: string; balloonNumber: number; @@ -57,7 +83,7 @@ export type BalloonFeature = { sortOrder: number; }; -export type BallooningDiagramContent = { +export type BalloonDocumentContent = { drawingNumber: string | null; revision: string | null; pdfUrl: string | null; @@ -65,6 +91,31 @@ export type BallooningDiagramContent = { features: BalloonFeature[]; }; +export type BalloonAnchorCreateItem = z.infer< + typeof balloonAnchorCreateItemValidator +>; +export type BalloonAnchorUpdateItem = z.infer< + typeof balloonAnchorUpdateItemValidator +>; +export type BalloonAnchorDeleteIds = z.infer< + typeof balloonAnchorDeleteValidator +>["ids"]; + +export type BalloonCreateFromPayloadItem = z.infer< + typeof balloonCreateFromPayloadItemValidator +>; +export type BalloonUpdateItem = z.infer; +export type BalloonDeleteIds = z.infer["ids"]; +export type BalloonAnnotationCreateItem = z.infer< + typeof balloonAnnotationCreateItemValidator +>; +export type BalloonAnnotationUpdateItem = z.infer< + typeof balloonAnnotationUpdateItemValidator +>; +export type BalloonAnnotationDeleteIds = z.infer< + typeof balloonAnnotationDeleteValidator +>["ids"]; + export type Gauge = NonNullable< Awaited>["data"] >[number]; diff --git a/apps/erp/app/modules/quality/ui/Ballooning/BalloonDiagramEditor.tsx b/apps/erp/app/modules/quality/ui/BalloonDocument/BalloonDocumentEditor.tsx similarity index 50% rename from apps/erp/app/modules/quality/ui/Ballooning/BalloonDiagramEditor.tsx rename to apps/erp/app/modules/quality/ui/BalloonDocument/BalloonDocumentEditor.tsx index 1a906aced..b0f7c4543 100644 --- a/apps/erp/app/modules/quality/ui/Ballooning/BalloonDiagramEditor.tsx +++ b/apps/erp/app/modules/quality/ui/BalloonDocument/BalloonDocumentEditor.tsx @@ -38,8 +38,9 @@ import { import { useFetcher } from "react-router"; import * as XLSX from "xlsx"; import { useUser } from "~/hooks"; -import type { BallooningDiagramContent } from "~/modules/quality/types"; -import { buildBallooningPdfWithOverlaysBytes } from "./exportBallooningPdfWithOverlays"; +import type { BalloonDocumentContent } from "~/modules/quality/types"; +import { path } from "~/utils/path"; +import { buildBalloonDocumentPdfWithOverlaysBytes } from "./exportBalloonDocumentPdfWithOverlays"; type DragState = { startX: number; @@ -47,12 +48,53 @@ type DragState = { currentX: number; currentY: number; } | null; -type DragKind = "selector" | "zoom" | null; +type DragKind = + | "selector" + | "zoom" + | "annotation" + | "annotationResize" + | "balloonMove" + | "selectorResize" + | null; + +type SelectorResizeHandle = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw"; + +type AnnotationRecord = { + id: string; + pageNumber: number; + x: number; + y: number; + width: number; + height: number; + text: string; + fontSize: number; +}; + +type AnnotationDraft = { + pageNumber: number; + x: number; + y: number; + width: number; + height: number; + text: string; + fontSize: number; +}; + +type AnnotationEditDraft = { + id: string; + pageNumber: number; + x: number; + y: number; + width: number; + height: number; + text: string; + fontSize: number; +}; -type BalloonDiagramEditorProps = { +type BalloonDocumentEditorProps = { diagramId: string; name: string; - content: BallooningDiagramContent | null; + content: BalloonDocumentContent | null; selectors: Array>; balloons: Array>; }; @@ -69,6 +111,13 @@ function toPercent(px: number, total: number) { const EDITOR_SPLITTER_H = 8; const MIN_PDF_PANE_PX = 160; +const ANNOTATION_DIALOG_WIDTH_PX = 220; +const ANNOTATION_DIALOG_HEIGHT_PX = 140; +const ANNOTATION_DIALOG_GAP_PX = 8; +const SELECTOR_RESIZE_HANDLE_PX = 10; +const SELECTOR_MIN_SIZE_PX = 12; +const ANNOTATION_RESIZE_HANDLE_PX = 10; +const ANNOTATION_MIN_SIZE_PX = 12; /** When the features table is expanded it keeps at least half the editor stack; PDF height is capped accordingly. */ function clampPdfPaneHeight( @@ -87,6 +136,97 @@ function clampPdfPaneHeight( return Math.min(maxPdf, Math.max(MIN_PDF_PANE_PX, pdfPx)); } +function getAnnotationDialogPosition(args: { + renderedWidth: number; + overlayHeight: number; + totalPagesStage: number; + pageNumber: number; + x: number; + y: number; + width: number; + height: number; +}) { + const { + renderedWidth, + overlayHeight, + totalPagesStage, + pageNumber, + x, + y, + width, + height + } = args; + + const annLeft = (x / 100) * renderedWidth; + const annTop = + ((pageNumber - 1) / totalPagesStage) * overlayHeight + + (y / 100) * (overlayHeight / totalPagesStage); + const annWidth = (width / 100) * renderedWidth; + const annHeight = (height / 100) * (overlayHeight / totalPagesStage); + const annRight = annLeft + annWidth; + const annBottom = annTop + annHeight; + + const maxLeft = Math.max(8, renderedWidth - ANNOTATION_DIALOG_WIDTH_PX); + const maxTop = Math.max(8, overlayHeight - ANNOTATION_DIALOG_HEIGHT_PX); + + const rightCandidate = annRight + ANNOTATION_DIALOG_GAP_PX; + const leftCandidate = + annLeft - ANNOTATION_DIALOG_WIDTH_PX - ANNOTATION_DIALOG_GAP_PX; + + let left = rightCandidate; + if (rightCandidate <= maxLeft) { + left = rightCandidate; + } else if (leftCandidate >= 8) { + left = leftCandidate; + } else { + left = Math.max(8, Math.min(maxLeft, rightCandidate)); + } + + let top = Math.max(8, Math.min(maxTop, annTop)); + + const dialogRight = left + ANNOTATION_DIALOG_WIDTH_PX; + const dialogBottom = top + ANNOTATION_DIALOG_HEIGHT_PX; + const overlapsAnnotation = + left < annRight && + dialogRight > annLeft && + top < annBottom && + dialogBottom > annTop; + + if (overlapsAnnotation) { + const belowCandidate = annBottom + ANNOTATION_DIALOG_GAP_PX; + const aboveCandidate = + annTop - ANNOTATION_DIALOG_HEIGHT_PX - ANNOTATION_DIALOG_GAP_PX; + if (belowCandidate <= maxTop) { + top = belowCandidate; + } else if (aboveCandidate >= 8) { + top = aboveCandidate; + } else { + top = Math.max(8, Math.min(maxTop, belowCandidate)); + } + } + + return { left, top }; +} + +function cursorForSelectorResizeHandle(handle: SelectorResizeHandle): string { + switch (handle) { + case "n": + case "s": + return "ns-resize"; + case "e": + case "w": + return "ew-resize"; + case "ne": + case "sw": + return "nesw-resize"; + case "nw": + case "se": + return "nwse-resize"; + default: + return "pointer"; + } +} + /** Callout / selector stroke — matches reference (orange border, hollow fill). */ const CALLOUT_STROKE = "#f97316"; const CALLOUT_TEXT = "#171717"; @@ -95,13 +235,6 @@ const CALLOUT_TEXT = "#171717"; * Konva 9 does not apply the `cursor` prop to the DOM; Transformer only sets * `stage.content.style.cursor` manually. Use these helpers for hover/drag cursors. */ -function konvaContentFromTarget(target: unknown): HTMLElement | null { - const t = target as { - getStage?: () => { content?: HTMLElement } | null; - } | null; - return t?.getStage?.()?.content ?? null; -} - function konvaContentFromStageRef(stageRef: { current: unknown; }): HTMLElement | null { @@ -210,6 +343,17 @@ type FeatureRow = { const BALLOON_W_NORM = 0.04; const BALLOON_H_NORM = 0.04; const BALLOON_OFFSET_NORM = 0.02; +const BALLOON_W_PCT = BALLOON_W_NORM * 100; +const BALLOON_H_PCT = BALLOON_H_NORM * 100; +const BALLOON_OFFSET_PCT = BALLOON_OFFSET_NORM * 100; + +function nextBalloonLabel(rows: FeatureRow[]): string { + const nums = rows + .map((r) => parseInt(r.label, 10)) + .filter((n) => Number.isFinite(n)); + const max = nums.length ? Math.max(...nums) : 0; + return String(max + 1); +} function isTempBalloonId(balloonId: string) { return balloonId.startsWith("temp-bln-"); @@ -236,239 +380,6 @@ function triggerDownload(blob: Blob, filename: string) { URL.revokeObjectURL(url); } -type NormBalloonRect = { - pageNumber: number; - x: number; - y: number; - width: number; - height: number; -}; - -function clamp01Norm(n: number) { - return Math.max(0, Math.min(1, n)); -} - -function overlapsNorm(a: NormBalloonRect, b: NormBalloonRect) { - if (a.pageNumber !== b.pageNumber) return false; - return !( - a.x + a.width <= b.x || - b.x + b.width <= a.x || - a.y + a.height <= b.y || - b.y + b.height <= a.y - ); -} - -function inBoundsNorm(rect: NormBalloonRect) { - return ( - rect.x >= 0 && - rect.y >= 0 && - rect.x + rect.width <= 1 && - rect.y + rect.height <= 1 - ); -} - -function clampRectToBoundsNorm(rect: NormBalloonRect): NormBalloonRect { - const x = clamp01Norm(Math.min(rect.x, 1 - rect.width)); - const y = clamp01Norm(Math.min(rect.y, 1 - rect.height)); - return { ...rect, x, y }; -} - -const MIN_SELECTOR_DIM_PCT = 1; - -type ResizeHandleId = "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w"; - -function stagePointToPageLocalPercent( - stageX: number, - stageY: number, - pageNumber: number, - renderedWidth: number, - overlayHeight: number, - totalPages: number -): { lx: number; ly: number } { - const tp = Math.max(1, totalPages); - const pageHeightPx = overlayHeight / tp; - const lx = (stageX / renderedWidth) * 100; - const localYpx = stageY - (pageNumber - 1) * pageHeightPx; - const ly = (localYpx / pageHeightPx) * 100; - return { - lx: Math.max(0, Math.min(100, lx)), - ly: Math.max(0, Math.min(100, ly)) - }; -} - -function clampSelectorPagePercentPct(r: { - x: number; - y: number; - width: number; - height: number; -}): { x: number; y: number; width: number; height: number } { - let { x, y, width, height } = r; - width = Math.max(MIN_SELECTOR_DIM_PCT, Math.min(width, 100)); - height = Math.max(MIN_SELECTOR_DIM_PCT, Math.min(height, 100)); - x = Math.max(0, Math.min(x, 100 - width)); - y = Math.max(0, Math.min(y, 100 - height)); - return { x, y, width, height }; -} - -function applySelectorResizeDelta( - handle: ResizeHandleId, - start: { x: number; y: number; width: number; height: number }, - dlx: number, - dly: number -): { x: number; y: number; width: number; height: number } { - const { x, y, width, height } = start; - let nx = x; - let ny = y; - let nw = width; - let nh = height; - switch (handle) { - case "e": - nw = width + dlx; - break; - case "w": - nx = x + dlx; - nw = width - dlx; - break; - case "s": - nh = height + dly; - break; - case "n": - ny = y + dly; - nh = height - dly; - break; - case "se": - nw = width + dlx; - nh = height + dly; - break; - case "sw": - nx = x + dlx; - nw = width - dlx; - nh = height + dly; - break; - case "ne": - ny = y + dly; - nw = width + dlx; - nh = height - dly; - break; - case "nw": - nx = x + dlx; - ny = y + dly; - nw = width - dlx; - nh = height - dly; - break; - default: - break; - } - return clampSelectorPagePercentPct({ x: nx, y: ny, width: nw, height: nh }); -} - -function cursorForResizeHandle(handle: ResizeHandleId): string { - switch (handle) { - case "n": - case "s": - return "ns-resize"; - case "e": - case "w": - return "ew-resize"; - case "nw": - case "se": - return "nwse-resize"; - case "ne": - case "sw": - return "nesw-resize"; - default: - return "pointer"; - } -} - -function featureRowToOccupiedNorm(row: FeatureRow): NormBalloonRect { - return { - pageNumber: row.pageNumber, - x: row.x / 100, - y: row.y / 100, - width: row.width / 100, - height: row.height / 100 - }; -} - -/** Mirrors server `createBalloonsForSelectors` placement candidates. */ -function computeBalloonPlacementFromSelector( - selector: { - pageNumber: number; - x: number; - y: number; - width: number; - height: number; - }, - occupied: NormBalloonRect[] -): NormBalloonRect { - const balloonWidth = BALLOON_W_NORM; - const balloonHeight = BALLOON_H_NORM; - const offset = BALLOON_OFFSET_NORM; - const s = selector; - const candidates: NormBalloonRect[] = [ - { - pageNumber: s.pageNumber, - x: s.x + s.width + offset, - y: s.y, - width: balloonWidth, - height: balloonHeight - }, - { - pageNumber: s.pageNumber, - x: s.x + s.width + offset, - y: s.y - balloonHeight - offset, - width: balloonWidth, - height: balloonHeight - }, - { - pageNumber: s.pageNumber, - x: s.x + s.width + offset, - y: s.y + s.height + offset, - width: balloonWidth, - height: balloonHeight - }, - { - pageNumber: s.pageNumber, - x: s.x, - y: s.y - balloonHeight - offset, - width: balloonWidth, - height: balloonHeight - }, - { - pageNumber: s.pageNumber, - x: s.x, - y: s.y + s.height + offset, - width: balloonWidth, - height: balloonHeight - }, - { - pageNumber: s.pageNumber, - x: s.x - balloonWidth - offset, - y: s.y, - width: balloonWidth, - height: balloonHeight - } - ]; - - const placed = - candidates.find( - (candidate) => - inBoundsNorm(candidate) && - !occupied.some((other) => overlapsNorm(candidate, other)) - ) ?? clampRectToBoundsNorm(candidates[0]!); - - return placed; -} - -function nextBalloonLabel(rows: FeatureRow[]): string { - const nums = rows - .map((r) => parseInt(r.label, 10)) - .filter((n) => Number.isFinite(n)); - const max = nums.length ? Math.max(...nums) : 0; - return String(max + 1); -} - function buildBalloonDataForSave( row: FeatureRow, sel: SelectorRect | undefined @@ -568,13 +479,13 @@ function mapFeatureRowFromBalloon(b: Record): FeatureRow { }; } -export default function BalloonDiagramEditor({ +export default function BalloonDocumentEditor({ diagramId, name, content, selectors, balloons -}: BalloonDiagramEditorProps) { +}: BalloonDocumentEditorProps) { const { t } = useLingui(); const fetcher = useFetcher<{ success: boolean; @@ -597,6 +508,7 @@ export default function BalloonDiagramEditor({ balloons.map(mapFeatureRowFromBalloon) ); const [placing, setPlacing] = useState(false); + const [placingAnnotation, setPlacingAnnotation] = useState(false); const [zoomBoxMode, setZoomBoxMode] = useState(false); const [zoomScale, setZoomScale] = useState(1); const [numPages, setNumPages] = useState(0); @@ -617,6 +529,24 @@ export default function BalloonDiagramEditor({ const containerRef = useRef(null); const stageRef = useRef(null); const editorStackRef = useRef(null); + const balloonDragRef = useRef<{ + balloonId: string; + startX: number; + startY: number; + } | null>(null); + const selectorResizeRef = useRef<{ + selectorId: string; + handle: SelectorResizeHandle; + startRect: Pick; + } | null>(null); + const annotationResizeRef = useRef<{ + annotationId: string; + handle: SelectorResizeHandle; + startRect: Pick< + AnnotationRecord, + "x" | "y" | "width" | "height" | "pageNumber" + >; + } | null>(null); const splitDragRef = useRef<{ startY: number; startPdfPx: number } | null>( null ); @@ -627,66 +557,24 @@ export default function BalloonDiagramEditor({ const pendingBalloonDeleteIdsRef = useRef(new Set()); const pendingSelectorDeleteIdsRef = useRef(new Set()); + const [annotations, setAnnotations] = useState([]); + const [selectedAnnotationId, setSelectedAnnotationId] = useState< + string | null + >(null); const [selectedBalloonId, setSelectedBalloonId] = useState( null ); const [selectedSelectorId, setSelectedSelectorId] = useState( null ); - - type BalloonDragSession = { - balloonId: string; - startPointer: { x: number; y: number }; - startRow: { x: number; y: number; width: number; height: number }; - renderedWidth: number; - pageHeightPx: number; - }; - - type SelectorResizeSession = { - selectorId: string; - handle: ResizeHandleId; - startRect: { x: number; y: number; width: number; height: number }; - pageNumber: number; - startPointerLocal: { lx: number; ly: number }; - renderedWidth: number; - overlayHeight: number; - totalPages: number; - }; - - const balloonDragSessionRef = useRef(null); - const selectorResizeSessionRef = useRef(null); - const onBalloonDragMoveRef = useRef<(ev: MouseEvent) => void>(() => {}); - const onBalloonDragUpRef = useRef<(ev: MouseEvent) => void>(() => {}); - const onSelectorResizeMoveRef = useRef<(ev: MouseEvent) => void>(() => {}); - const onSelectorResizeUpRef = useRef<(ev: MouseEvent) => void>(() => {}); - - const finalizeBalloonDrag = useCallback(() => { - const session = balloonDragSessionRef.current; - window.removeEventListener("mousemove", onBalloonDragMoveRef.current); - window.removeEventListener("mouseup", onBalloonDragUpRef.current); - balloonDragSessionRef.current = null; - const stageContent = konvaContentFromStageRef(stageRef); - if (stageContent) stageContent.style.cursor = ""; - if (!session) return; - setFeatureRows((prev) => - prev.map((r) => { - if (r.balloonId !== session.balloonId) return r; - if (isTempBalloonId(r.balloonId)) return r; - const moved = - Math.abs(r.x - session.startRow.x) > 0.05 || - Math.abs(r.y - session.startRow.y) > 0.05; - return moved ? { ...r, balloonDirty: true } : r; - }) - ); - }, []); - - const finalizeSelectorResize = useCallback(() => { - window.removeEventListener("mousemove", onSelectorResizeMoveRef.current); - window.removeEventListener("mouseup", onSelectorResizeUpRef.current); - selectorResizeSessionRef.current = null; - const stageContent = konvaContentFromStageRef(stageRef); - if (stageContent) stageContent.style.cursor = ""; - }, []); + const [annotationDraft, setAnnotationDraft] = + useState(null); + const [annotationFontSizeInput, setAnnotationFontSizeInput] = + useState("12"); + const [annotationEditDraft, setAnnotationEditDraft] = + useState(null); + const [annotationEditFontSizeInput, setAnnotationEditFontSizeInput] = + useState("12"); useEffect(() => { setIsMounted(true); @@ -764,7 +652,7 @@ export default function BalloonDiagramEditor({ }); ro.observe(overlayRef.current); return () => ro.disconnect(); - }, [numPages, containerWidth, pdfUrl, pdfFile]); + }, []); useEffect(() => { if (fetcher.data?.success === true) { @@ -784,6 +672,71 @@ export default function BalloonDiagramEditor({ } }, [fetcher.data, t]); + const loadAnnotations = useCallback(async () => { + try { + const response = await fetch( + `${path.to.balloonDocument(diagramId)}/annotation/get` + ); + const payload = (await response.json()) as { + success?: boolean; + data?: Array>; + }; + if (!response.ok || payload.success !== true) return; + setAnnotations( + (payload.data ?? []).map((row) => ({ + id: String(row.id), + pageNumber: Number(row.pageNumber ?? 1), + x: Number(row.xCoordinate ?? 0) * 100, + y: Number(row.yCoordinate ?? 0) * 100, + width: Number(row.width ?? 0.16) * 100, + height: Number(row.height ?? 0.06) * 100, + text: String(row.text ?? ""), + fontSize: Number( + ( + row.style as + | { + fontSize?: unknown; + } + | null + | undefined + )?.fontSize ?? 12 + ) + })) + ); + } catch { + // Best-effort background load; keep editor usable if this fails. + } + }, [diagramId]); + + useEffect(() => { + void loadAnnotations(); + }, [loadAnnotations]); + + useEffect(() => { + if (!selectedAnnotationId || annotationDraft) { + setAnnotationEditDraft(null); + return; + } + const selected = annotations.find( + (item) => item.id === selectedAnnotationId + ); + if (!selected) { + setAnnotationEditDraft(null); + return; + } + setAnnotationEditDraft({ + id: selected.id, + pageNumber: selected.pageNumber, + x: selected.x, + y: selected.y, + width: selected.width, + height: selected.height, + text: selected.text, + fontSize: selected.fontSize + }); + setAnnotationEditFontSizeInput(String(selected.fontSize)); + }, [selectedAnnotationId, annotations, annotationDraft]); + const getRelativePosFromStage = useCallback(() => { const stage = stageRef.current as { getPointerPosition: () => { x: number; y: number } | null; @@ -797,191 +750,41 @@ export default function BalloonDiagramEditor({ return { x: toPercent(pos.x, w), y: toPercent(pos.y, h) }; }, []); - const getStagePointerPx = useCallback(() => { - const stage = stageRef.current as { - getPointerPosition: () => { x: number; y: number } | null; - } | null; - return stage?.getPointerPosition?.() ?? null; - }, []); - - const beginBalloonPointerDrag = useCallback( - ( - balloonId: string, - rowSnapshot: { x: number; y: number; width: number; height: number }, - renderedWidth: number, - overlayHeight: number, - totalPages: number - ) => { - if ( - balloonDragSessionRef.current || - selectorResizeSessionRef.current || - !renderedWidth || - !overlayHeight - ) { - return; - } - const pos = getStagePointerPx(); - if (!pos) return; - const tp = Math.max(1, totalPages); - const pageHeightPx = overlayHeight / tp; - balloonDragSessionRef.current = { - balloonId, - startPointer: { x: pos.x, y: pos.y }, - startRow: { ...rowSnapshot }, - renderedWidth, - pageHeightPx - }; - const onMove = () => { - const session = balloonDragSessionRef.current; - if (!session) return; - const p = getStagePointerPx(); - if (!p) return; - const dx = - ((p.x - session.startPointer.x) / session.renderedWidth) * 100; - const dy = - ((p.y - session.startPointer.y) / session.pageHeightPx) * 100; - const nx = Math.max( - 0, - Math.min(100 - session.startRow.width, session.startRow.x + dx) - ); - const ny = Math.max( - 0, - Math.min(100 - session.startRow.height, session.startRow.y + dy) - ); - setFeatureRows((prev) => - prev.map((r) => - r.balloonId === session.balloonId ? { ...r, x: nx, y: ny } : r - ) - ); - }; - const onUp = () => { - finalizeBalloonDrag(); - }; - onBalloonDragMoveRef.current = onMove; - onBalloonDragUpRef.current = onUp; - window.addEventListener("mousemove", onMove); - window.addEventListener("mouseup", onUp); - const stageContent = konvaContentFromStageRef(stageRef); - if (stageContent) stageContent.style.cursor = "grabbing"; - }, - [getStagePointerPx, finalizeBalloonDrag] - ); - - const beginSelectorResize = useCallback( - ( - selectorId: string, - handle: ResizeHandleId, - startRect: { x: number; y: number; width: number; height: number }, - pageNumber: number, - renderedWidth: number, - overlayHeight: number, - totalPages: number - ) => { - if ( - balloonDragSessionRef.current || - selectorResizeSessionRef.current || - !renderedWidth || - !overlayHeight - ) { - return; - } - const pos = getStagePointerPx(); - if (!pos) return; - const local = stagePointToPageLocalPercent( - pos.x, - pos.y, - pageNumber, - renderedWidth, - overlayHeight, - totalPages + const persistAnnotationResize = useCallback( + async (annotation: AnnotationRecord) => { + const formData = new FormData(); + formData.set( + "items", + JSON.stringify([ + { + id: annotation.id, + pageNumber: annotation.pageNumber, + xCoordinate: annotation.x / 100, + yCoordinate: annotation.y / 100, + width: annotation.width / 100, + height: annotation.height / 100 + } + ]) ); - selectorResizeSessionRef.current = { - selectorId, - handle, - startRect: { ...startRect }, - pageNumber, - startPointerLocal: { lx: local.lx, ly: local.ly }, - renderedWidth, - overlayHeight, - totalPages - }; - const onMove = () => { - const session = selectorResizeSessionRef.current; - if (!session) return; - const p = getStagePointerPx(); - if (!p) return; - const cur = stagePointToPageLocalPercent( - p.x, - p.y, - session.pageNumber, - session.renderedWidth, - session.overlayHeight, - session.totalPages - ); - const dlx = cur.lx - session.startPointerLocal.lx; - const dly = cur.ly - session.startPointerLocal.ly; - const next = applySelectorResizeDelta( - session.handle, - session.startRect, - dlx, - dly - ); - setSelectorRects((prev) => - prev.map((s) => - s.id === session.selectorId - ? { - ...s, - x: next.x, - y: next.y, - width: next.width, - height: next.height, - isDirty: !s.isNew ? true : s.isDirty - } - : s - ) + try { + const response = await fetch( + `${path.to.balloonDocument(diagramId)}/annotation/update`, + { method: "POST", body: formData } ); - const anchorX = next.x + next.width / 2; - const anchorY = next.y + next.height / 2; - setFeatureRows((prev) => - prev.map((r) => - r.selectorId !== session.selectorId - ? r - : { - ...r, - anchorX, - anchorY, - balloonDirty: isTempBalloonId(r.balloonId) - ? r.balloonDirty - : true - } - ) - ); - }; - const onUp = () => { - finalizeSelectorResize(); - }; - onSelectorResizeMoveRef.current = onMove; - onSelectorResizeUpRef.current = onUp; - window.addEventListener("mousemove", onMove); - window.addEventListener("mouseup", onUp); - const stageContent = konvaContentFromStageRef(stageRef); - if (stageContent) { - stageContent.style.cursor = cursorForResizeHandle(handle); + const payload = (await response.json()) as { + success?: boolean; + message?: string; + }; + if (!response.ok || payload.success !== true) { + toast.error(payload.message ?? t`Failed to update annotation`); + await loadAnnotations(); + } + } catch { + toast.error(t`Failed to update annotation`); + await loadAnnotations(); } }, - [getStagePointerPx, finalizeSelectorResize] - ); - - useEffect( - () => () => { - window.removeEventListener("mousemove", onBalloonDragMoveRef.current); - window.removeEventListener("mouseup", onBalloonDragUpRef.current); - window.removeEventListener("mousemove", onSelectorResizeMoveRef.current); - window.removeEventListener("mouseup", onSelectorResizeUpRef.current); - balloonDragSessionRef.current = null; - selectorResizeSessionRef.current = null; - }, - [] + [diagramId, loadAnnotations, t] ); const finalizeDragAt = useCallback( @@ -994,6 +797,15 @@ export default function BalloonDiagramEditor({ const rh = Math.abs(y - drag.startY); if (rw < 0.5 || rh < 0.5) { + if (dragKind === "balloonMove") { + balloonDragRef.current = null; + } + if (dragKind === "annotationResize") { + annotationResizeRef.current = null; + } + if (dragKind === "selectorResize") { + selectorResizeRef.current = null; + } setDragKind(null); setDrag(null); return; @@ -1035,80 +847,439 @@ export default function BalloonDiagramEditor({ return; } - const totalPages = Math.max(1, pdfMetrics?.pageCount ?? numPages ?? 1); - const pageHeightPct = 100 / totalPages; - const pageNumber = Math.min( - totalPages, - Math.max(1, Math.floor(ry / pageHeightPct) + 1) - ); - const pageStartPct = (pageNumber - 1) * pageHeightPct; - const localY = ((ry - pageStartPct) / pageHeightPct) * 100; - const localHeight = (rh / pageHeightPct) * 100; - const clippedLocalHeight = Math.min(localHeight, 100 - localY); + if (dragKind === "annotation") { + const totalPages = Math.max(1, pdfMetrics?.pageCount ?? numPages ?? 1); + const pageHeightPct = 100 / totalPages; + const pageNumber = Math.min( + totalPages, + Math.max(1, Math.floor(ry / pageHeightPct) + 1) + ); + const pageStartPct = (pageNumber - 1) * pageHeightPct; + const localY = ((ry - pageStartPct) / pageHeightPct) * 100; + const localHeight = (rh / pageHeightPct) * 100; + const clippedLocalHeight = Math.min(localHeight, 100 - localY); - if (clippedLocalHeight < 0.5) { + if (clippedLocalHeight < 0.5) { + setDragKind(null); + setDrag(null); + return; + } + + setAnnotationDraft({ + pageNumber, + x: rx, + y: localY, + width: rw, + height: clippedLocalHeight, + text: "", + fontSize: 12 + }); + setAnnotationFontSizeInput("12"); setDragKind(null); setDrag(null); + setPlacingAnnotation(false); return; } - const tempId = `temp-${nanoid()}`; + if (dragKind === "annotationResize" && annotationResizeRef.current) { + const activeResize = annotationResizeRef.current; + const totalPages = Math.max(1, pdfMetrics?.pageCount ?? numPages ?? 1); + const stageWidthPctBase = Math.max(1, containerWidth * zoomScale); + const pageHeightPx = overlayHeight / totalPages; + const minWidthPct = Math.max( + 0.5, + (ANNOTATION_MIN_SIZE_PX / stageWidthPctBase) * 100 + ); + const minHeightPct = Math.max( + 0.5, + (ANNOTATION_MIN_SIZE_PX / Math.max(1, pageHeightPx)) * 100 + ); + const start = activeResize.startRect; + const deltaX = x - drag.startX; + const deltaY = y - drag.startY; + let nextX = start.x; + let nextY = start.y; + let nextW = start.width; + let nextH = start.height; + + if (activeResize.handle.includes("e")) { + nextW = Math.max( + minWidthPct, + Math.min(100 - start.x, start.width + deltaX) + ); + } + if (activeResize.handle.includes("s")) { + nextH = Math.max( + minHeightPct, + Math.min(100 - start.y, start.height + deltaY) + ); + } + if (activeResize.handle.includes("w")) { + const limitedX = Math.max( + 0, + Math.min(start.x + start.width - minWidthPct, start.x + deltaX) + ); + nextX = limitedX; + nextW = start.width - (limitedX - start.x); + } + if (activeResize.handle.includes("n")) { + const limitedY = Math.max( + 0, + Math.min(start.y + start.height - minHeightPct, start.y + deltaY) + ); + nextY = limitedY; + nextH = start.height - (limitedY - start.y); + } + + const resized = { + x: Math.max(0, Math.min(100 - nextW, nextX)), + y: Math.max(0, Math.min(100 - nextH, nextY)), + width: nextW, + height: nextH + }; - const normSelector = { - pageNumber, - x: rx / 100, - y: localY / 100, - width: rw / 100, - height: clippedLocalHeight / 100 - }; - const anchorXNorm = clamp01Norm(normSelector.x + normSelector.width / 2); - const anchorYNorm = clamp01Norm(normSelector.y + normSelector.height / 2); - - setFeatureRows((prev) => { - const occupied = prev.map(featureRowToOccupiedNorm); - const placed = computeBalloonPlacementFromSelector( - normSelector, - occupied + const finalAnnotation: AnnotationRecord = { + id: activeResize.annotationId, + pageNumber: start.pageNumber, + ...resized, + text: + annotations.find((item) => item.id === activeResize.annotationId) + ?.text ?? "", + fontSize: + annotations.find((item) => item.id === activeResize.annotationId) + ?.fontSize ?? 12 + }; + + setAnnotations((prev) => + prev.map((a) => + a.id === activeResize.annotationId ? { ...a, ...resized } : a + ) ); - const label = nextBalloonLabel(prev); + setAnnotationEditDraft((prev) => + prev && prev.id === activeResize.annotationId + ? { ...prev, ...resized } + : prev + ); + annotationResizeRef.current = null; + setDragKind(null); + setDrag(null); + void persistAnnotationResize(finalAnnotation); + return; + } + + if (dragKind === "selector") { + const totalPages = Math.max(1, pdfMetrics?.pageCount ?? numPages ?? 1); + const pageHeightPct = 100 / totalPages; + const pageNumber = Math.min( + totalPages, + Math.max(1, Math.floor(ry / pageHeightPct) + 1) + ); + const pageStartPct = (pageNumber - 1) * pageHeightPct; + const localY = ((ry - pageStartPct) / pageHeightPct) * 100; + const localHeight = (rh / pageHeightPct) * 100; + const clippedLocalHeight = Math.min(localHeight, 100 - localY); + + if (clippedLocalHeight < 0.5) { + setDragKind(null); + setDrag(null); + return; + } + + const tempSelectorId = `temp-${nanoid()}`; const tempBalloonId = `temp-bln-${nanoid()}`; - const row: FeatureRow = { - balloonId: tempBalloonId, - selectorId: tempId, - label, - pageNumber, - x: placed.x * 100, - y: placed.y * 100, - width: BALLOON_W_NORM * 100, - height: BALLOON_H_NORM * 100, - anchorX: anchorXNorm * 100, - anchorY: anchorYNorm * 100, - featureName: `Feature ${label}`, - nominalValue: "", - tolerancePlus: "", - toleranceMinus: "", - units: "" - }; - return [...prev, row]; - }); + const anchorX = Math.max(0, Math.min(100, rx + rw / 2)); + const anchorY = Math.max( + 0, + Math.min(100, localY + clippedLocalHeight / 2) + ); - setSelectorRects((prev) => [ - ...prev, - { - id: tempId, - pageNumber, - x: rx, - y: localY, - width: rw, - height: clippedLocalHeight, - isNew: true, - isDirty: false + let balloonX = rx + rw + BALLOON_OFFSET_PCT; + if (balloonX + BALLOON_W_PCT > 100) { + balloonX = rx - BALLOON_OFFSET_PCT - BALLOON_W_PCT; } - ]); + balloonX = Math.max(0, Math.min(100 - BALLOON_W_PCT, balloonX)); + const balloonY = Math.max(0, Math.min(100 - BALLOON_H_PCT, localY)); + + setSelectorRects((prev) => [ + ...prev, + { + id: tempSelectorId, + pageNumber, + x: rx, + y: localY, + width: rw, + height: clippedLocalHeight, + isNew: true, + isDirty: false + } + ]); + + setFeatureRows((prev) => { + const label = nextBalloonLabel(prev); + return [ + ...prev, + { + balloonId: tempBalloonId, + selectorId: tempSelectorId, + label, + pageNumber, + x: balloonX, + y: balloonY, + width: BALLOON_W_PCT, + height: BALLOON_H_PCT, + anchorX, + anchorY, + featureName: `Feature ${label}`, + nominalValue: "", + tolerancePlus: "", + toleranceMinus: "", + units: "" + } + ]; + }); + + setDragKind(null); + setDrag(null); + setPlacing(false); + return; + } + + if (dragKind === "balloonMove") { + balloonDragRef.current = null; + setDragKind(null); + setDrag(null); + return; + } + + if (dragKind === "selectorResize") { + selectorResizeRef.current = null; + setDragKind(null); + setDrag(null); + return; + } + setDragKind(null); setDrag(null); }, - [drag, pdfMetrics, numPages, dragKind, zoomScale, overlayHeight] + [ + drag, + pdfMetrics, + numPages, + dragKind, + zoomScale, + overlayHeight, + containerWidth, + annotations, + persistAnnotationResize + ] + ); + + const getAnnotationIdAt = useCallback( + (x: number, y: number): string | null => { + const totalPages = Math.max(1, pdfMetrics?.pageCount ?? numPages ?? 1); + const pageHeightPct = 100 / totalPages; + const pageNumber = Math.min( + totalPages, + Math.max(1, Math.floor(y / pageHeightPct) + 1) + ); + const pageStartPct = (pageNumber - 1) * pageHeightPct; + const localY = ((y - pageStartPct) / pageHeightPct) * 100; + + for (let i = annotations.length - 1; i >= 0; i -= 1) { + const annotation = annotations[i]; + if (annotation.pageNumber !== pageNumber) continue; + const inRect = + x >= annotation.x && + x <= annotation.x + annotation.width && + localY >= annotation.y && + localY <= annotation.y + annotation.height; + if (inRect) return annotation.id; + } + + return null; + }, + [annotations, pdfMetrics?.pageCount, numPages] + ); + + const getAnnotationResizeHandleAt = useCallback( + ( + x: number, + y: number + ): { annotationId: string; handle: SelectorResizeHandle } | null => { + const totalPages = Math.max(1, pdfMetrics?.pageCount ?? numPages ?? 1); + const pageHeightPct = 100 / totalPages; + const pageNumber = Math.min( + totalPages, + Math.max(1, Math.floor(y / pageHeightPct) + 1) + ); + const pageStartPct = (pageNumber - 1) * pageHeightPct; + const localY = ((y - pageStartPct) / pageHeightPct) * 100; + const pageHeightPx = overlayHeight / totalPages; + const stageWidthPctBase = Math.max(1, containerWidth * zoomScale); + const hPad = Math.max( + 0.5, + (ANNOTATION_RESIZE_HANDLE_PX / stageWidthPctBase) * 100 + ); + const vPad = Math.max( + 0.5, + (ANNOTATION_RESIZE_HANDLE_PX / Math.max(1, pageHeightPx)) * 100 + ); + + for (let i = annotations.length - 1; i >= 0; i -= 1) { + const a = annotations[i]; + if (a.pageNumber !== pageNumber) continue; + const left = a.x; + const right = a.x + a.width; + const top = a.y; + const bottom = a.y + a.height; + const nearLeft = Math.abs(x - left) <= hPad; + const nearRight = Math.abs(x - right) <= hPad; + const nearTop = Math.abs(localY - top) <= vPad; + const nearBottom = Math.abs(localY - bottom) <= vPad; + const withinXBand = x >= left - hPad && x <= right + hPad; + const withinYBand = localY >= top - vPad && localY <= bottom + vPad; + if (!withinXBand || !withinYBand) continue; + + if (nearTop && nearLeft) return { annotationId: a.id, handle: "nw" }; + if (nearTop && nearRight) return { annotationId: a.id, handle: "ne" }; + if (nearBottom && nearLeft) return { annotationId: a.id, handle: "sw" }; + if (nearBottom && nearRight) + return { annotationId: a.id, handle: "se" }; + if (nearTop) return { annotationId: a.id, handle: "n" }; + if (nearBottom) return { annotationId: a.id, handle: "s" }; + if (nearLeft) return { annotationId: a.id, handle: "w" }; + if (nearRight) return { annotationId: a.id, handle: "e" }; + } + + return null; + }, + [ + annotations, + pdfMetrics?.pageCount, + numPages, + overlayHeight, + containerWidth, + zoomScale + ] + ); + + const getBalloonIdAt = useCallback( + (x: number, y: number): string | null => { + const totalPages = Math.max(1, pdfMetrics?.pageCount ?? numPages ?? 1); + const pageHeightPct = 100 / totalPages; + const pageNumber = Math.min( + totalPages, + Math.max(1, Math.floor(y / pageHeightPct) + 1) + ); + const pageStartPct = (pageNumber - 1) * pageHeightPct; + const localY = ((y - pageStartPct) / pageHeightPct) * 100; + + for (let i = featureRows.length - 1; i >= 0; i -= 1) { + const balloon = featureRows[i]; + if (balloon.pageNumber !== pageNumber) continue; + const inRect = + x >= balloon.x && + x <= balloon.x + balloon.width && + localY >= balloon.y && + localY <= balloon.y + balloon.height; + if (inRect) return balloon.balloonId; + } + + return null; + }, + [featureRows, pdfMetrics?.pageCount, numPages] + ); + + const getSelectorIdAt = useCallback( + (x: number, y: number): string | null => { + const totalPages = Math.max(1, pdfMetrics?.pageCount ?? numPages ?? 1); + const pageHeightPct = 100 / totalPages; + const pageNumber = Math.min( + totalPages, + Math.max(1, Math.floor(y / pageHeightPct) + 1) + ); + const pageStartPct = (pageNumber - 1) * pageHeightPct; + const localY = ((y - pageStartPct) / pageHeightPct) * 100; + + for (let i = selectorRects.length - 1; i >= 0; i -= 1) { + const selector = selectorRects[i]; + if (selector.pageNumber !== pageNumber) continue; + const inRect = + x >= selector.x && + x <= selector.x + selector.width && + localY >= selector.y && + localY <= selector.y + selector.height; + if (inRect) return selector.id; + } + + return null; + }, + [selectorRects, pdfMetrics?.pageCount, numPages] + ); + + const getSelectorResizeHandleAt = useCallback( + ( + x: number, + y: number + ): { selectorId: string; handle: SelectorResizeHandle } | null => { + const totalPages = Math.max(1, pdfMetrics?.pageCount ?? numPages ?? 1); + const pageHeightPct = 100 / totalPages; + const pageNumber = Math.min( + totalPages, + Math.max(1, Math.floor(y / pageHeightPct) + 1) + ); + const pageStartPct = (pageNumber - 1) * pageHeightPct; + const localY = ((y - pageStartPct) / pageHeightPct) * 100; + + const pageHeightPx = overlayHeight / totalPages; + const stageWidthPctBase = Math.max(1, containerWidth * zoomScale); + const hPad = Math.max( + 0.5, + (SELECTOR_RESIZE_HANDLE_PX / stageWidthPctBase) * 100 + ); + const vPad = Math.max( + 0.5, + (SELECTOR_RESIZE_HANDLE_PX / Math.max(1, pageHeightPx)) * 100 + ); + + for (let i = selectorRects.length - 1; i >= 0; i -= 1) { + const s = selectorRects[i]; + if (s.pageNumber !== pageNumber) continue; + + const left = s.x; + const right = s.x + s.width; + const top = s.y; + const bottom = s.y + s.height; + + const nearLeft = Math.abs(x - left) <= hPad; + const nearRight = Math.abs(x - right) <= hPad; + const nearTop = Math.abs(localY - top) <= vPad; + const nearBottom = Math.abs(localY - bottom) <= vPad; + + const withinXBand = x >= left - hPad && x <= right + hPad; + const withinYBand = localY >= top - vPad && localY <= bottom + vPad; + if (!withinXBand || !withinYBand) continue; + + if (nearTop && nearLeft) return { selectorId: s.id, handle: "nw" }; + if (nearTop && nearRight) return { selectorId: s.id, handle: "ne" }; + if (nearBottom && nearLeft) return { selectorId: s.id, handle: "sw" }; + if (nearBottom && nearRight) return { selectorId: s.id, handle: "se" }; + if (nearTop) return { selectorId: s.id, handle: "n" }; + if (nearBottom) return { selectorId: s.id, handle: "s" }; + if (nearLeft) return { selectorId: s.id, handle: "w" }; + if (nearRight) return { selectorId: s.id, handle: "e" }; + } + + return null; + }, + [ + selectorRects, + pdfMetrics?.pageCount, + numPages, + overlayHeight, + containerWidth, + zoomScale + ] ); const handleStageMouseDown = useCallback( @@ -1122,14 +1293,6 @@ export default function BalloonDiagramEditor({ const evt = ke.evt; if (!evt) return; - if (!placing && !zoomBoxMode) { - const target = ke.target; - if (target && target === stageRef.current) { - setSelectedBalloonId(null); - setSelectedSelectorId(null); - } - } - if (placing) { evt.preventDefault(); const { x, y } = getRelativePosFromStage(); @@ -1137,6 +1300,15 @@ export default function BalloonDiagramEditor({ setDrag({ startX: x, startY: y, currentX: x, currentY: y }); return; } + + if (placingAnnotation) { + evt.preventDefault(); + const { x, y } = getRelativePosFromStage(); + setDragKind("annotation"); + setDrag({ startX: x, startY: y, currentX: x, currentY: y }); + return; + } + if (zoomBoxMode) { evt.preventDefault(); const { x, y } = getRelativePosFromStage(); @@ -1144,8 +1316,331 @@ export default function BalloonDiagramEditor({ setDrag({ startX: x, startY: y, currentX: x, currentY: y }); return; } + + const { x, y } = getRelativePosFromStage(); + const annotationId = getAnnotationIdAt(x, y); + const annotationResize = getAnnotationResizeHandleAt(x, y); + if (annotationResize) { + const annotation = annotations.find( + (a) => a.id === annotationResize.annotationId + ); + if (annotation) { + setSelectedAnnotationId(annotation.id); + setSelectedBalloonId(null); + setSelectedSelectorId(null); + annotationResizeRef.current = { + annotationId: annotation.id, + handle: annotationResize.handle, + startRect: { + x: annotation.x, + y: annotation.y, + width: annotation.width, + height: annotation.height, + pageNumber: annotation.pageNumber + } + }; + evt.preventDefault(); + setDragKind("annotationResize"); + setDrag({ startX: x, startY: y, currentX: x, currentY: y }); + return; + } + } + + if (annotationId) { + setSelectedAnnotationId(annotationId); + setSelectedBalloonId(null); + setSelectedSelectorId(null); + return; + } + + const selectorResize = getSelectorResizeHandleAt(x, y); + if (selectorResize) { + const selector = selectorRects.find( + (s) => s.id === selectorResize.selectorId + ); + if (selector) { + const linkedBalloonId = + featureRows.find((row) => row.selectorId === selector.id) + ?.balloonId ?? null; + setSelectedSelectorId(selector.id); + setSelectedBalloonId(linkedBalloonId); + setSelectedAnnotationId(null); + selectorResizeRef.current = { + selectorId: selector.id, + handle: selectorResize.handle, + startRect: { + x: selector.x, + y: selector.y, + width: selector.width, + height: selector.height + } + }; + evt.preventDefault(); + setDragKind("selectorResize"); + setDrag({ startX: x, startY: y, currentX: x, currentY: y }); + return; + } + } + + const balloonId = getBalloonIdAt(x, y); + if (balloonId) { + const linkedSelectorId = + featureRows.find((row) => row.balloonId === balloonId)?.selectorId ?? + null; + setSelectedBalloonId(balloonId); + setSelectedAnnotationId(null); + setSelectedSelectorId(linkedSelectorId); + const dragged = featureRows.find((row) => row.balloonId === balloonId); + if (dragged) { + evt.preventDefault(); + balloonDragRef.current = { + balloonId, + startX: dragged.x, + startY: dragged.y + }; + setDragKind("balloonMove"); + setDrag({ startX: x, startY: y, currentX: x, currentY: y }); + } + return; + } + + const selectorId = getSelectorIdAt(x, y); + if (selectorId) { + const linkedBalloonId = + featureRows.find((row) => row.selectorId === selectorId)?.balloonId ?? + null; + setSelectedSelectorId(selectorId); + setSelectedAnnotationId(null); + setSelectedBalloonId(linkedBalloonId); + return; + } + + setSelectedAnnotationId(null); + setSelectedBalloonId(null); + setSelectedSelectorId(null); + }, + [ + placingAnnotation, + placing, + getRelativePosFromStage, + zoomBoxMode, + getAnnotationIdAt, + getAnnotationResizeHandleAt, + getSelectorResizeHandleAt, + getBalloonIdAt, + getSelectorIdAt, + annotations, + featureRows, + selectorRects + ] + ); + + const handleCreateAnnotation = useCallback(async () => { + if (!annotationDraft || annotationDraft.text.trim().length === 0) { + toast.error(t`Annotation text is required`); + return; + } + const formData = new FormData(); + formData.set( + "items", + JSON.stringify([ + { + pageNumber: annotationDraft.pageNumber, + xCoordinate: annotationDraft.x / 100, + yCoordinate: annotationDraft.y / 100, + width: annotationDraft.width / 100, + height: annotationDraft.height / 100, + text: annotationDraft.text.trim(), + style: { + fontSize: annotationDraft.fontSize + } + } + ]) + ); + + try { + const response = await fetch( + `${path.to.balloonDocument(diagramId)}/annotation/create`, + { + method: "POST", + body: formData + } + ); + const payload = (await response.json()) as { + success?: boolean; + message?: string; + }; + if (!response.ok || payload.success !== true) { + toast.error(payload.message ?? t`Failed to create annotation`); + return; + } + setAnnotationDraft(null); + setAnnotationFontSizeInput("12"); + await loadAnnotations(); + toast.success(t`Annotation added`); + } catch { + toast.error(t`Failed to create annotation`); + } + }, [annotationDraft, diagramId, loadAnnotations, t]); + + const handleUpdateAnnotation = useCallback(async () => { + if (!annotationEditDraft || annotationEditDraft.text.trim().length === 0) { + toast.error(t`Annotation text is required`); + return; + } + const formData = new FormData(); + formData.set( + "items", + JSON.stringify([ + { + id: annotationEditDraft.id, + pageNumber: annotationEditDraft.pageNumber, + xCoordinate: annotationEditDraft.x / 100, + yCoordinate: annotationEditDraft.y / 100, + width: annotationEditDraft.width / 100, + height: annotationEditDraft.height / 100, + text: annotationEditDraft.text.trim(), + style: { + fontSize: annotationEditDraft.fontSize + } + } + ]) + ); + try { + const response = await fetch( + `${path.to.balloonDocument(diagramId)}/annotation/update`, + { + method: "POST", + body: formData + } + ); + const payload = (await response.json()) as { + success?: boolean; + message?: string; + }; + if (!response.ok || payload.success !== true) { + toast.error(payload.message ?? t`Failed to update annotation`); + return; + } + await loadAnnotations(); + setSelectedAnnotationId(null); + setAnnotationEditDraft(null); + toast.success(t`Annotation updated`); + } catch { + toast.error(t`Failed to update annotation`); + } + }, [annotationEditDraft, diagramId, loadAnnotations, t]); + + const handleDeleteAnnotation = useCallback(async () => { + if (!annotationEditDraft) return; + const formData = new FormData(); + formData.set("ids", JSON.stringify([annotationEditDraft.id])); + try { + const response = await fetch( + `${path.to.balloonDocument(diagramId)}/annotation/delete`, + { + method: "POST", + body: formData + } + ); + const payload = (await response.json()) as { + success?: boolean; + message?: string; + }; + if (!response.ok || payload.success !== true) { + toast.error(payload.message ?? t`Failed to delete annotation`); + return; + } + setSelectedAnnotationId(null); + setAnnotationEditDraft(null); + await loadAnnotations(); + toast.success(t`Annotation deleted`); + } catch { + toast.error(t`Failed to delete annotation`); + } + }, [annotationEditDraft, diagramId, loadAnnotations, t]); + + const getHoverCursorAt = useCallback( + ( + x: number, + y: number + ): + | "" + | "pointer" + | "ew-resize" + | "ns-resize" + | "nwse-resize" + | "nesw-resize" => { + const totalPages = Math.max(1, pdfMetrics?.pageCount ?? numPages ?? 1); + const pageHeightPct = 100 / totalPages; + const pageNumber = Math.min( + totalPages, + Math.max(1, Math.floor(y / pageHeightPct) + 1) + ); + const pageStartPct = (pageNumber - 1) * pageHeightPct; + const localY = ((y - pageStartPct) / pageHeightPct) * 100; + + const inRect = ( + left: number, + top: number, + width: number, + height: number + ) => + x >= left && + x <= left + width && + localY >= top && + localY <= top + height; + + // Top-most visual priority: annotation -> balloon -> anchor. + const annotationResize = getAnnotationResizeHandleAt(x, y); + if (annotationResize) { + return cursorForSelectorResizeHandle(annotationResize.handle); + } + + for (const annotation of annotations) { + if (annotation.pageNumber !== pageNumber) continue; + if ( + inRect( + annotation.x, + annotation.y, + annotation.width, + annotation.height + ) + ) { + return "pointer"; + } + } + + for (const balloon of featureRows) { + if (balloon.pageNumber !== pageNumber) continue; + if (inRect(balloon.x, balloon.y, balloon.width, balloon.height)) { + return "pointer"; + } + } + + const selectorResize = getSelectorResizeHandleAt(x, y); + if (selectorResize) { + return cursorForSelectorResizeHandle(selectorResize.handle); + } + + for (const selector of selectorRects) { + if (selector.pageNumber !== pageNumber) continue; + if (inRect(selector.x, selector.y, selector.width, selector.height)) { + return "pointer"; + } + } + + return ""; }, - [placing, zoomBoxMode, getRelativePosFromStage] + [ + annotations, + featureRows, + selectorRects, + pdfMetrics?.pageCount, + numPages, + getAnnotationResizeHandleAt, + getSelectorResizeHandleAt + ] ); const handleStageMouseMove = useCallback( @@ -1153,11 +1648,241 @@ export default function BalloonDiagramEditor({ const evt = (e as { evt?: MouseEvent }).evt; if (!evt) return; - if (!drag) return; const { x, y } = getRelativePosFromStage(); + + const stageContent = konvaContentFromStageRef(stageRef); + if (placing || placingAnnotation || zoomBoxMode || drag) { + if (stageContent) { + if (dragKind === "balloonMove") { + stageContent.style.cursor = "grabbing"; + } else if ( + dragKind === "annotationResize" && + annotationResizeRef.current + ) { + stageContent.style.cursor = cursorForSelectorResizeHandle( + annotationResizeRef.current.handle + ); + } else if ( + dragKind === "selectorResize" && + selectorResizeRef.current + ) { + stageContent.style.cursor = cursorForSelectorResizeHandle( + selectorResizeRef.current.handle + ); + } else { + stageContent.style.cursor = ""; + } + } + } else if (stageContent) { + stageContent.style.cursor = getHoverCursorAt(x, y); + } + + if (dragKind === "balloonMove" && drag && balloonDragRef.current) { + const activeDrag = balloonDragRef.current; + const deltaX = x - drag.startX; + const deltaY = y - drag.startY; + setFeatureRows((prev) => + prev.map((row) => { + if (row.balloonId !== activeDrag.balloonId) return row; + const nextX = Math.max( + 0, + Math.min(100 - row.width, activeDrag.startX + deltaX) + ); + const nextY = Math.max( + 0, + Math.min(100 - row.height, activeDrag.startY + deltaY) + ); + return { + ...row, + x: nextX, + y: nextY, + balloonDirty: row.balloonId.startsWith("temp-bln-") + ? row.balloonDirty + : true + }; + }) + ); + } + + if ( + dragKind === "annotationResize" && + drag && + annotationResizeRef.current + ) { + const activeResize = annotationResizeRef.current; + const totalPages = Math.max(1, pdfMetrics?.pageCount ?? numPages ?? 1); + const pageHeightPx = overlayHeight / totalPages; + const stageWidthPctBase = Math.max(1, containerWidth * zoomScale); + const minWidthPct = Math.max( + 0.5, + (ANNOTATION_MIN_SIZE_PX / stageWidthPctBase) * 100 + ); + const minHeightPct = Math.max( + 0.5, + (ANNOTATION_MIN_SIZE_PX / Math.max(1, pageHeightPx)) * 100 + ); + const start = activeResize.startRect; + const deltaX = x - drag.startX; + const deltaY = y - drag.startY; + let nextX = start.x; + let nextY = start.y; + let nextW = start.width; + let nextH = start.height; + + if (activeResize.handle.includes("e")) { + nextW = Math.max( + minWidthPct, + Math.min(100 - start.x, start.width + deltaX) + ); + } + if (activeResize.handle.includes("s")) { + nextH = Math.max( + minHeightPct, + Math.min(100 - start.y, start.height + deltaY) + ); + } + if (activeResize.handle.includes("w")) { + const limitedX = Math.max( + 0, + Math.min(start.x + start.width - minWidthPct, start.x + deltaX) + ); + nextX = limitedX; + nextW = start.width - (limitedX - start.x); + } + if (activeResize.handle.includes("n")) { + const limitedY = Math.max( + 0, + Math.min(start.y + start.height - minHeightPct, start.y + deltaY) + ); + nextY = limitedY; + nextH = start.height - (limitedY - start.y); + } + + const resized = { + x: Math.max(0, Math.min(100 - nextW, nextX)), + y: Math.max(0, Math.min(100 - nextH, nextY)), + width: nextW, + height: nextH + }; + + setAnnotations((prev) => + prev.map((a) => + a.id === activeResize.annotationId ? { ...a, ...resized } : a + ) + ); + setAnnotationEditDraft((prev) => + prev && prev.id === activeResize.annotationId + ? { ...prev, ...resized } + : prev + ); + } + + if (dragKind === "selectorResize" && drag && selectorResizeRef.current) { + const activeResize = selectorResizeRef.current; + const deltaX = x - drag.startX; + const deltaY = y - drag.startY; + const totalPages = Math.max(1, pdfMetrics?.pageCount ?? numPages ?? 1); + const pageHeightPx = overlayHeight / totalPages; + const stageWidthPctBase = Math.max(1, containerWidth * zoomScale); + const minWidthPct = Math.max( + 0.5, + (SELECTOR_MIN_SIZE_PX / stageWidthPctBase) * 100 + ); + const minHeightPct = Math.max( + 0.5, + (SELECTOR_MIN_SIZE_PX / Math.max(1, pageHeightPx)) * 100 + ); + const start = activeResize.startRect; + let nextX = start.x; + let nextY = start.y; + let nextW = start.width; + let nextH = start.height; + + if (activeResize.handle.includes("e")) { + nextW = Math.max( + minWidthPct, + Math.min(100 - start.x, start.width + deltaX) + ); + } + if (activeResize.handle.includes("s")) { + nextH = Math.max( + minHeightPct, + Math.min(100 - start.y, start.height + deltaY) + ); + } + if (activeResize.handle.includes("w")) { + const limitedX = Math.max( + 0, + Math.min(start.x + start.width - minWidthPct, start.x + deltaX) + ); + nextX = limitedX; + nextW = start.width - (limitedX - start.x); + } + if (activeResize.handle.includes("n")) { + const limitedY = Math.max( + 0, + Math.min(start.y + start.height - minHeightPct, start.y + deltaY) + ); + nextY = limitedY; + nextH = start.height - (limitedY - start.y); + } + + const resizedRect = { + x: Math.max(0, Math.min(100 - nextW, nextX)), + y: Math.max(0, Math.min(100 - nextH, nextY)), + width: nextW, + height: nextH + }; + + setSelectorRects((prev) => + prev.map((selector) => + selector.id !== activeResize.selectorId + ? selector + : { ...selector, ...resizedRect, isDirty: true } + ) + ); + + const centerX = Math.max( + 0, + Math.min(100, resizedRect.x + resizedRect.width / 2) + ); + const centerY = Math.max( + 0, + Math.min(100, resizedRect.y + resizedRect.height / 2) + ); + setFeatureRows((prev) => + prev.map((row) => + row.selectorId !== activeResize.selectorId + ? row + : { + ...row, + anchorX: centerX, + anchorY: centerY, + balloonDirty: isTempBalloonId(row.balloonId) + ? row.balloonDirty + : true + } + ) + ); + } + + if (!drag) return; setDrag((d) => (d ? { ...d, currentX: x, currentY: y } : null)); }, - [drag, getRelativePosFromStage] + [ + drag, + dragKind, + getRelativePosFromStage, + getHoverCursorAt, + placing, + placingAnnotation, + zoomBoxMode, + overlayHeight, + pdfMetrics?.pageCount, + numPages, + containerWidth, + zoomScale + ] ); const handleStageMouseUp = useCallback( @@ -1255,7 +1980,7 @@ export default function BalloonDiagramEditor({ } fetcher.submit(formData, { method: "post", - action: `/x/ballooning-diagram/${diagramId}/save` + action: path.to.saveBalloonDocument(diagramId) }); }, [ diagramId, @@ -1275,7 +2000,7 @@ export default function BalloonDiagramEditor({ setUploading(true); setPdfFile(file); - const storagePath = `${companyId}/ballooning/${diagramId}/${nanoid()}.pdf`; + const storagePath = `${companyId}/balloonDocument/${diagramId}/${nanoid()}.pdf`; const result = await carbon.storage .from("private") .upload(storagePath, file); @@ -1297,10 +2022,8 @@ export default function BalloonDiagramEditor({ const hasPdf = pdfFile !== null || pdfUrl !== ""; const handleDeleteFeature = useCallback((balloonId: string) => { - let selectorIdToRemove: string | undefined; setFeatureRows((prev) => { const row = prev.find((r) => r.balloonId === balloonId); - selectorIdToRemove = row?.selectorId; if (row) { if (!isTempBalloonId(row.balloonId)) { pendingBalloonDeleteIdsRef.current.add(row.balloonId); @@ -1325,10 +2048,6 @@ export default function BalloonDiagramEditor({ }); return nextRows; }); - setSelectedBalloonId((cur) => (cur === balloonId ? null : cur)); - setSelectedSelectorId((cur) => - selectorIdToRemove && cur === selectorIdToRemove ? null : cur - ); }, []); const updateFeatureField = useCallback( @@ -1435,14 +2154,16 @@ export default function BalloonDiagramEditor({ } bytes = await res.arrayBuffer(); } - const outBytes = await buildBallooningPdfWithOverlaysBytes({ + const outBytes = await buildBalloonDocumentPdfWithOverlaysBytes({ pdfBytes: bytes, featureRows, selectorRects, scale: 2 }); + const blobBytes = new Uint8Array(outBytes.byteLength); + blobBytes.set(outBytes); triggerDownload( - new Blob([outBytes], { type: "application/pdf" }), + new Blob([blobBytes], { type: "application/pdf" }), `${sanitizeFilenameBase(name)}-with-balloons.pdf` ); toast.success(t`PDF downloaded`); @@ -1453,24 +2174,21 @@ export default function BalloonDiagramEditor({ } }, [hasPdf, pdfFile, pdfUrl, name, featureRows, selectorRects, t]); - const previewRect = drag - ? { - x: Math.min(drag.startX, drag.currentX), - y: Math.min(drag.startY, drag.currentY), - width: Math.abs(drag.currentX - drag.startX), - height: Math.abs(drag.currentY - drag.startY) - } - : null; + const previewRect = + drag && + dragKind !== "balloonMove" && + dragKind !== "selectorResize" && + dragKind !== "annotationResize" + ? { + x: Math.min(drag.startX, drag.currentX), + y: Math.min(drag.startY, drag.currentY), + width: Math.abs(drag.currentX - drag.startX), + height: Math.abs(drag.currentY - drag.startY) + } + : null; const renderedWidth = containerWidth > 0 ? Math.max(1, containerWidth * zoomScale) : 0; const totalPagesStage = Math.max(1, pdfMetrics?.pageCount ?? numPages ?? 1); - const pdfOverlayInteract = - hasPdf && - !placing && - !zoomBoxMode && - renderedWidth > 0 && - overlayHeight > 0; - return (
{/* Hidden file input */} @@ -1506,11 +2224,8 @@ export default function BalloonDiagramEditor({ setPlacing((v) => { const next = !v; if (next) { + setPlacingAnnotation(false); setZoomBoxMode(false); - setSelectedBalloonId(null); - setSelectedSelectorId(null); - finalizeBalloonDrag(); - finalizeSelectorResize(); } return next; }); @@ -1519,6 +2234,24 @@ export default function BalloonDiagramEditor({ > {placing ? t`Drag to create selector` : t`Add Selector`} + )} + {annotationDraft && renderedWidth > 0 && overlayHeight > 0 && ( +
+ + + setAnnotationDraft((prev) => + prev ? { ...prev, text: event.target.value } : prev + ) + } + /> + { + const raw = event.target.value; + if (!/^\d*$/.test(raw)) return; + setAnnotationFontSizeInput(raw); + if (raw === "") return; + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return; + setAnnotationDraft((prev) => + prev + ? { + ...prev, + fontSize: Math.max(8, Math.min(48, parsed)) + } + : prev + ); + }} + onBlur={() => { + const parsed = Number(annotationFontSizeInput || "12"); + const normalized = Math.max( + 8, + Math.min(48, Number.isFinite(parsed) ? parsed : 12) + ); + setAnnotationFontSizeInput(String(normalized)); + setAnnotationDraft((prev) => + prev ? { ...prev, fontSize: normalized } : prev + ); + }} + /> + + + + + +
+ )} + {!annotationDraft && + annotationEditDraft && + renderedWidth > 0 && + overlayHeight > 0 && ( +
+ + + setAnnotationEditDraft((prev) => + prev ? { ...prev, text: event.target.value } : prev + ) + } + /> + { + const raw = event.target.value; + if (!/^\d*$/.test(raw)) return; + setAnnotationEditFontSizeInput(raw); + if (raw === "") return; + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return; + setAnnotationEditDraft((prev) => + prev + ? { + ...prev, + fontSize: Math.max(8, Math.min(48, parsed)) + } + : prev + ); + }} + onBlur={() => { + const parsed = Number( + annotationEditFontSizeInput || "12" + ); + const normalized = Math.max( + 8, + Math.min(48, Number.isFinite(parsed) ? parsed : 12) + ); + setAnnotationEditFontSizeInput(String(normalized)); + setAnnotationEditDraft((prev) => + prev ? { ...prev, fontSize: normalized } : prev + ); + }} + /> + + + + + + + + +
+ )}
{featuresTableExpanded ? ( @@ -2015,6 +2900,7 @@ export default function BalloonDiagramEditor({ role="separator" aria-orientation="horizontal" aria-label={t`Drag to resize diagram and features`} + aria-valuenow={Math.round(pdfPaneHeightPx)} className={`group flex h-2 shrink-0 cursor-row-resize touch-none items-center justify-center rounded-md px-2 hover:bg-muted/80 ${ isResizingPdfFeatures ? "bg-muted" : "" }`} @@ -2106,21 +2992,12 @@ export default function BalloonDiagramEditor({ {featureRows.map((row) => ( { - if ( - e.target instanceof HTMLInputElement || - e.target instanceof HTMLButtonElement - ) { - return; - } - setSelectedBalloonId(row.balloonId); - setSelectedSelectorId(row.selectorId); - }} > void; }; -export default function BallooningForm({ +export default function BalloonDocumentForm({ initialValues, onClose -}: BallooningFormProps) { +}: BalloonDocumentFormProps) { const { t } = useLingui(); const { carbon } = useCarbon(); const user = useUser(); @@ -48,7 +48,7 @@ export default function BallooningForm({ setUploading(true); const tempId = initialValues.id ?? nanoid(); - const storagePath = `${user.company.id}/ballooning/${tempId}/${nanoid()}.pdf`; + const storagePath = `${user.company.id}/balloonDocument/${tempId}/${nanoid()}.pdf`; const result = await carbon.storage .from("private") .upload(storagePath, file); @@ -67,21 +67,19 @@ export default function BallooningForm({ !open && onClose()}> - {isEditing - ? t`Edit Ballooning Diagram` - : t`New Ballooning Diagram`} + {isEditing ? t`Edit Balloon Document` : t`New Balloon Document`} diff --git a/apps/erp/app/modules/quality/ui/BalloonDocument/BalloonDocumentTable.tsx b/apps/erp/app/modules/quality/ui/BalloonDocument/BalloonDocumentTable.tsx new file mode 100644 index 000000000..07c427e1c --- /dev/null +++ b/apps/erp/app/modules/quality/ui/BalloonDocument/BalloonDocumentTable.tsx @@ -0,0 +1,159 @@ +import { MenuIcon, MenuItem, useDisclosure } from "@carbon/react"; +import { formatDate } from "@carbon/utils"; +import { useLingui } from "@lingui/react/macro"; +import type { ColumnDef } from "@tanstack/react-table"; +import { memo, useCallback, useMemo, useState } from "react"; +import { flushSync } from "react-dom"; +import { + LuFileText, + LuPencil, + LuTarget, + LuTrash, + LuUser +} from "react-icons/lu"; +import { useNavigate } from "react-router"; +import { EmployeeAvatar, Hyperlink, New, Table } from "~/components"; +import { ConfirmDelete } from "~/components/Modals"; +import { usePermissions, useUrlParams } from "~/hooks"; +import { path } from "~/utils/path"; +import type { BalloonDocument } from "../../types"; + +type BalloonDocumentTableProps = { + data: BalloonDocument[]; + count: number; +}; + +const defaultColumnVisibility = { + createdAt: false, + updatedAt: false, + updatedBy: false +}; + +const BalloonDocumentTable = memo( + ({ data, count }: BalloonDocumentTableProps) => { + const [params] = useUrlParams(); + const navigate = useNavigate(); + const { t } = useLingui(); + const permissions = usePermissions(); + + const deleteDisclosure = useDisclosure(); + const [selectedDiagram, setSelectedDiagram] = + useState(null); + + const columns = useMemo[]>( + () => [ + { + accessorKey: "name", + header: t`Name`, + cell: ({ row }) => ( + + {row.original.name} + + ), + meta: { icon: } + }, + { + id: "createdBy", + header: t`Created By`, + cell: ({ row }) => ( + + ), + meta: { icon: } + }, + { + accessorKey: "createdAt", + header: t`Created At`, + cell: (item) => formatDate(item.getValue()), + meta: { icon: } + }, + { + id: "updatedBy", + header: t`Updated By`, + cell: ({ row }) => ( + + ), + meta: { icon: } + }, + { + accessorKey: "updatedAt", + header: t`Updated At`, + cell: (item) => formatDate(item.getValue()), + meta: { icon: } + } + ], + [t] + ); + + const renderContextMenu = useCallback( + (row: BalloonDocument) => ( + <> + { + navigate( + `${path.to.balloonDocument(row.id)}?${params?.toString()}` + ); + }} + > + } /> + Edit Diagram + + { + flushSync(() => { + setSelectedDiagram(row); + }); + deleteDisclosure.onOpen(); + }} + > + } /> + Delete Diagram + + + ), + [permissions, navigate, params, deleteDisclosure] + ); + + return ( + <> + + data={data} + columns={columns} + count={count} + defaultColumnVisibility={defaultColumnVisibility} + primaryAction={ + permissions.can("create", "quality") && ( + + ) + } + renderContextMenu={renderContextMenu} + title={t`Balloon Documents`} + /> + {deleteDisclosure.isOpen && selectedDiagram && ( + { + setSelectedDiagram(null); + deleteDisclosure.onClose(); + }} + onSubmit={() => { + setSelectedDiagram(null); + deleteDisclosure.onClose(); + }} + name={selectedDiagram.name} + text={t`Are you sure you want to delete this balloon document?`} + /> + )} + + ); + } +); + +BalloonDocumentTable.displayName = "BalloonDocumentTable"; +export default BalloonDocumentTable; diff --git a/apps/erp/app/modules/quality/ui/Ballooning/exportBallooningPdfWithOverlays.ts b/apps/erp/app/modules/quality/ui/BalloonDocument/exportBalloonDocumentPdfWithOverlays.ts similarity index 98% rename from apps/erp/app/modules/quality/ui/Ballooning/exportBallooningPdfWithOverlays.ts rename to apps/erp/app/modules/quality/ui/BalloonDocument/exportBalloonDocumentPdfWithOverlays.ts index 554c71735..62aeed441 100644 --- a/apps/erp/app/modules/quality/ui/Ballooning/exportBallooningPdfWithOverlays.ts +++ b/apps/erp/app/modules/quality/ui/BalloonDocument/exportBalloonDocumentPdfWithOverlays.ts @@ -181,7 +181,7 @@ function drawMarkupOnPageCanvas( /** * Rasterizes each PDF page with selector + balloon markup (matching the Konva overlay) and builds a new PDF. */ -export async function buildBallooningPdfWithOverlaysBytes(args: { +export async function buildBalloonDocumentPdfWithOverlaysBytes(args: { pdfBytes: ArrayBuffer; featureRows: ExportFeatureRow[]; selectorRects: ExportSelectorRect[]; diff --git a/apps/erp/app/modules/quality/ui/BalloonDocument/index.ts b/apps/erp/app/modules/quality/ui/BalloonDocument/index.ts new file mode 100644 index 000000000..d08124107 --- /dev/null +++ b/apps/erp/app/modules/quality/ui/BalloonDocument/index.ts @@ -0,0 +1,8 @@ +/** + * Do not barrel-export BalloonDocumentEditor: it depends on react-konva → Konva + * Node build → `require("canvas")`, which breaks Vite SSR for any route that + * only imports BalloonDocumentForm / BalloonDocumentTable from this file. + * Import the editor only via direct path + lazy/ClientOnly (see balloon/$id). + */ +export { default as BalloonDocumentForm } from "./BalloonDocumentForm"; +export { default as BalloonDocumentTable } from "./BalloonDocumentTable"; diff --git a/apps/erp/app/modules/quality/ui/Ballooning/BallooningTable.tsx b/apps/erp/app/modules/quality/ui/Ballooning/BallooningTable.tsx deleted file mode 100644 index 770301d80..000000000 --- a/apps/erp/app/modules/quality/ui/Ballooning/BallooningTable.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { MenuIcon, MenuItem, useDisclosure } from "@carbon/react"; -import { formatDate } from "@carbon/utils"; -import { useLingui } from "@lingui/react/macro"; -import type { ColumnDef } from "@tanstack/react-table"; -import { memo, useCallback, useMemo, useState } from "react"; -import { flushSync } from "react-dom"; -import { - LuFileText, - LuPencil, - LuTarget, - LuTrash, - LuUser -} from "react-icons/lu"; -import { useNavigate } from "react-router"; -import { EmployeeAvatar, Hyperlink, New, Table } from "~/components"; -import { ConfirmDelete } from "~/components/Modals"; -import { usePermissions, useUrlParams } from "~/hooks"; -import { path } from "~/utils/path"; -import type { BallooningDiagram } from "../../types"; - -type BallooningTableProps = { - data: BallooningDiagram[]; - count: number; -}; - -const defaultColumnVisibility = { - createdAt: false, - updatedAt: false, - updatedBy: false -}; - -const BallooningTable = memo(({ data, count }: BallooningTableProps) => { - const [params] = useUrlParams(); - const navigate = useNavigate(); - const { t } = useLingui(); - const permissions = usePermissions(); - - const deleteDisclosure = useDisclosure(); - const [selectedDiagram, setSelectedDiagram] = - useState(null); - - const columns = useMemo[]>( - () => [ - { - accessorKey: "name", - header: t`Name`, - cell: ({ row }) => ( - - {row.original.name} - - ), - meta: { icon: } - }, - { - id: "createdBy", - header: t`Created By`, - cell: ({ row }) => ( - - ), - meta: { icon: } - }, - { - accessorKey: "createdAt", - header: t`Created At`, - cell: (item) => formatDate(item.getValue()), - meta: { icon: } - }, - { - id: "updatedBy", - header: t`Updated By`, - cell: ({ row }) => ( - - ), - meta: { icon: } - }, - { - accessorKey: "updatedAt", - header: t`Updated At`, - cell: (item) => formatDate(item.getValue()), - meta: { icon: } - } - ], - [t] - ); - - const renderContextMenu = useCallback( - (row: BallooningDiagram) => ( - <> - { - navigate( - `${path.to.ballooningDiagram(row.id)}?${params?.toString()}` - ); - }} - > - } /> - Edit Diagram - - { - flushSync(() => { - setSelectedDiagram(row); - }); - deleteDisclosure.onOpen(); - }} - > - } /> - Delete Diagram - - - ), - [permissions, navigate, params, deleteDisclosure] - ); - - return ( - <> - - data={data} - columns={columns} - count={count} - defaultColumnVisibility={defaultColumnVisibility} - primaryAction={ - permissions.can("create", "quality") && ( - - ) - } - renderContextMenu={renderContextMenu} - title={t`Ballooning Diagrams`} - /> - {deleteDisclosure.isOpen && selectedDiagram && ( - { - setSelectedDiagram(null); - deleteDisclosure.onClose(); - }} - onSubmit={() => { - setSelectedDiagram(null); - deleteDisclosure.onClose(); - }} - name={selectedDiagram.name} - text={t`Are you sure you want to delete this ballooning diagram?`} - /> - )} - - ); -}); - -BallooningTable.displayName = "BallooningTable"; -export default BallooningTable; diff --git a/apps/erp/app/modules/quality/ui/Ballooning/index.ts b/apps/erp/app/modules/quality/ui/Ballooning/index.ts deleted file mode 100644 index 952c86c4d..000000000 --- a/apps/erp/app/modules/quality/ui/Ballooning/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Do not barrel-export BalloonDiagramEditor: it depends on react-konva → Konva - * Node build → `require("canvas")`, which breaks Vite SSR for any route that - * only imports BallooningForm / BallooningTable from this file. - * Import the editor only via direct path + lazy/ClientOnly (see ballooning-diagram/$id). - */ -export { default as BallooningForm } from "./BallooningForm"; -export { default as BallooningTable } from "./BallooningTable"; diff --git a/apps/erp/app/modules/quality/ui/useQualitySubmodules.tsx b/apps/erp/app/modules/quality/ui/useQualitySubmodules.tsx index 38c34788e..18a49449d 100644 --- a/apps/erp/app/modules/quality/ui/useQualitySubmodules.tsx +++ b/apps/erp/app/modules/quality/ui/useQualitySubmodules.tsx @@ -66,8 +66,8 @@ export default function useQualitySubmodules() { name: t`Inspection`, routes: [ { - name: t`Ballooning Diagrams`, - to: path.to.ballooningDiagrams, + name: t`Balloon Documents`, + to: path.to.balloonDocuments, icon: } ] diff --git a/apps/erp/app/routes/api+/mcp+/lib/server.ts b/apps/erp/app/routes/api+/mcp+/lib/server.ts index fe44e3e36..8e71304f0 100644 --- a/apps/erp/app/routes/api+/mcp+/lib/server.ts +++ b/apps/erp/app/routes/api+/mcp+/lib/server.ts @@ -39,7 +39,7 @@ Tools are namespaced by module — use the prefix to discover related tools: - people_* — 24 read, 14 write, 6 delete tools - production_* — 62 read, 45 write, 22 delete tools - purchasing_* — 51 read, 34 write, 11 delete tools -- quality_* — 42 read, 25 write, 14 delete tools +- quality_* — 43 read, 27 write, 15 delete tools - resources_* — 47 read, 27 write, 20 delete tools - sales_* — 78 read, 54 write, 18 delete tools - settings_* — 26 read, 40 write, 2 delete tools diff --git a/apps/erp/app/routes/api+/mcp+/lib/tools/quality.ts b/apps/erp/app/routes/api+/mcp+/lib/tools/quality.ts index 30d934259..64f080cc0 100644 --- a/apps/erp/app/routes/api+/mcp+/lib/tools/quality.ts +++ b/apps/erp/app/routes/api+/mcp+/lib/tools/quality.ts @@ -78,19 +78,23 @@ import { upsertQualityDocument, upsertQualityDocumentStep, upsertRisk, - getBallooningDiagrams, - getBallooningDiagram, - upsertBallooningDiagram, - deleteBallooningDiagram, - getBallooningSelectors, - createBallooningSelectors, - updateBallooningSelectors, - deleteBallooningSelectors, - getBallooningBalloons, - createBalloonsForSelectors, - createBallooningBalloonsFromPayload, - updateBallooningBalloons, - deleteBallooningBalloons, + getBalloonDocuments, + getBalloonDocument, + upsertBalloonDocument, + deleteBalloonDocument, + getBalloonAnchors, + createBalloonAnchors, + updateBalloonAnchors, + deleteBalloonAnchors, + getBalloons, + createBalloonsForAnchors, + createBalloonsFromPayload, + updateBalloons, + deleteBalloons, + getBalloonAnnotations, + createBalloonAnnotations, + updateBalloonAnnotations, + deleteBalloonAnnotations, } from "~/modules/quality/quality.service"; import { nonConformanceReviewerValidator, @@ -103,7 +107,16 @@ import { qualityDocumentValidator, qualityDocumentStepValidator, riskRegisterValidator, - ballooningDiagramValidator, + balloonDocumentValidator, + balloonAnchorCreateItemValidator, + balloonAnchorUpdateItemValidator, + balloonAnchorDeleteValidator, + balloonCreateFromPayloadItemValidator, + balloonUpdateItemValidator, + balloonDeleteValidator, + balloonAnnotationCreateItemValidator, + balloonAnnotationUpdateItemValidator, + balloonAnnotationDeleteValidator, } from "~/modules/quality/quality.models"; export const registerQualityTools: RegisterTools = (server, ctx) => { @@ -1184,9 +1197,9 @@ export const registerQualityTools: RegisterTools = (server, ctx) => { ); server.registerTool( - "quality_getBallooningDiagrams", + "quality_getBalloonDocuments", { - description: "get ballooning diagrams", + description: "get balloon documents", inputSchema: { args: z.object({ limit: z.number().int().default(100), @@ -1196,155 +1209,135 @@ export const registerQualityTools: RegisterTools = (server, ctx) => { annotations: READ_ONLY_ANNOTATIONS, }, withErrorHandling(async (params) => { - const result = await getBallooningDiagrams(ctx.client, ctx.companyId, params.args); + const result = await getBalloonDocuments(ctx.client, ctx.companyId, params.args); return toMcpResult(result); - }, "Failed: quality_getBallooningDiagrams"), + }, "Failed: quality_getBalloonDocuments"), ); server.registerTool( - "quality_getBallooningDiagram", + "quality_getBalloonDocument", { - description: "get ballooning diagram", + description: "get balloon document", inputSchema: { id: z.string(), }, annotations: READ_ONLY_ANNOTATIONS, }, withErrorHandling(async (params) => { - const result = await getBallooningDiagram(ctx.client, params.id); + const result = await getBalloonDocument(ctx.client, params.id); return toMcpResult(result); - }, "Failed: quality_getBallooningDiagram"), + }, "Failed: quality_getBalloonDocument"), ); server.registerTool( - "quality_upsertBallooningDiagram", + "quality_upsertBalloonDocument", { - description: "upsert ballooning diagram", + description: "upsert balloon document", inputSchema: { - diagram: ballooningDiagramValidator, + diagram: balloonDocumentValidator, }, annotations: WRITE_ANNOTATIONS, }, withErrorHandling(async (params) => { - const result = await upsertBallooningDiagram(ctx.client, { ...params.diagram, companyId: ctx.companyId, createdBy: ctx.userId, updatedBy: ctx.userId }); + const result = await upsertBalloonDocument(ctx.client, { ...params.diagram, companyId: ctx.companyId, createdBy: ctx.userId, updatedBy: ctx.userId }); return toMcpResult(result); - }, "Failed: quality_upsertBallooningDiagram"), + }, "Failed: quality_upsertBalloonDocument"), ); server.registerTool( - "quality_deleteBallooningDiagram", + "quality_deleteBalloonDocument", { - description: "delete ballooning diagram", + description: "delete balloon document", inputSchema: { id: z.string(), }, annotations: DESTRUCTIVE_ANNOTATIONS, }, withErrorHandling(async (params) => { - const result = await deleteBallooningDiagram(ctx.client, params.id); + const result = await deleteBalloonDocument(ctx.client, params.id); return toMcpResult(result); - }, "Failed: quality_deleteBallooningDiagram"), + }, "Failed: quality_deleteBalloonDocument"), ); server.registerTool( - "quality_getBallooningSelectors", + "quality_getBalloonAnchors", { - description: "get ballooning selectors", + description: "get balloon anchors", inputSchema: { drawingId: z.string(), }, annotations: READ_ONLY_ANNOTATIONS, }, withErrorHandling(async (params) => { - const result = await getBallooningSelectors(ctx.client, params.drawingId); + const result = await getBalloonAnchors(ctx.client, params.drawingId); return toMcpResult(result); - }, "Failed: quality_getBallooningSelectors"), + }, "Failed: quality_getBalloonAnchors"), ); server.registerTool( - "quality_createBallooningSelectors", + "quality_createBalloonAnchors", { - description: "create ballooning selectors", + description: "create balloon anchors", inputSchema: { - args: z.object({ - drawingId: z.string(), - selectors: z.any(), - pageNumber: z.number(), - xCoordinate: z.number(), - yCoordinate: z.number(), - width: z.number(), - height: z.number() - }), + args: balloonAnchorCreateItemValidator, }, annotations: WRITE_ANNOTATIONS, }, withErrorHandling(async (params) => { - const result = await createBallooningSelectors(ctx.client, { ...params.args, companyId: ctx.companyId, createdBy: ctx.userId }); + const result = await createBalloonAnchors(ctx.client, { ...params.args, companyId: ctx.companyId, createdBy: ctx.userId }); return toMcpResult(result); - }, "Failed: quality_createBallooningSelectors"), + }, "Failed: quality_createBalloonAnchors"), ); server.registerTool( - "quality_updateBallooningSelectors", + "quality_updateBalloonAnchors", { - description: "update ballooning selectors", + description: "update balloon anchors", inputSchema: { - args: z.object({ - drawingId: z.string(), - selectors: z.any(), - id: z.string(), - pageNumber: z.number().optional(), - xCoordinate: z.number().optional(), - yCoordinate: z.number().optional(), - width: z.number().optional(), - height: z.number().optional() - }), + args: balloonAnchorUpdateItemValidator, }, annotations: WRITE_ANNOTATIONS, }, withErrorHandling(async (params) => { - const result = await updateBallooningSelectors(ctx.client, { ...params.args, companyId: ctx.companyId, updatedBy: ctx.userId }); + const result = await updateBalloonAnchors(ctx.client, { ...params.args, companyId: ctx.companyId, updatedBy: ctx.userId }); return toMcpResult(result); - }, "Failed: quality_updateBallooningSelectors"), + }, "Failed: quality_updateBalloonAnchors"), ); server.registerTool( - "quality_deleteBallooningSelectors", + "quality_deleteBalloonAnchors", { - description: "delete ballooning selectors", + description: "delete balloon anchors", inputSchema: { - args: z.object({ - drawingId: z.string(), - ids: z.array(z.string()) - }), + args: balloonAnchorDeleteValidator, }, annotations: DESTRUCTIVE_ANNOTATIONS, }, withErrorHandling(async (params) => { - const result = await deleteBallooningSelectors(ctx.client, { ...params.args, companyId: ctx.companyId, updatedBy: ctx.userId }); + const result = await deleteBalloonAnchors(ctx.client, { ...params.args, companyId: ctx.companyId, updatedBy: ctx.userId }); return toMcpResult(result); - }, "Failed: quality_deleteBallooningSelectors"), + }, "Failed: quality_deleteBalloonAnchors"), ); server.registerTool( - "quality_getBallooningBalloons", + "quality_getBalloons", { - description: "get ballooning balloons", + description: "get balloons", inputSchema: { drawingId: z.string(), }, annotations: READ_ONLY_ANNOTATIONS, }, withErrorHandling(async (params) => { - const result = await getBallooningBalloons(ctx.client, params.drawingId); + const result = await getBalloons(ctx.client, params.drawingId); return toMcpResult(result); - }, "Failed: quality_getBallooningBalloons"), + }, "Failed: quality_getBalloons"), ); server.registerTool( - "quality_createBalloonsForSelectors", + "quality_createBalloonsForAnchors", { - description: "create balloons for selectors", + description: "create balloons for anchors", inputSchema: { args: z.object({ drawingId: z.string(), @@ -1360,79 +1353,113 @@ export const registerQualityTools: RegisterTools = (server, ctx) => { annotations: WRITE_ANNOTATIONS, }, withErrorHandling(async (params) => { - const result = await createBalloonsForSelectors(ctx.client, { ...params.args, companyId: ctx.companyId, createdBy: ctx.userId }); + const result = await createBalloonsForAnchors(ctx.client, { ...params.args, companyId: ctx.companyId, createdBy: ctx.userId }); return toMcpResult(result); - }, "Failed: quality_createBalloonsForSelectors"), + }, "Failed: quality_createBalloonsForAnchors"), ); server.registerTool( - "quality_createBallooningBalloonsFromPayload", + "quality_createBalloonsFromPayload", { - description: "create ballooning balloons from payload", + description: "create balloons from payload", inputSchema: { - args: z.object({ - drawingId: z.string(), - selectorIdMap: z.any(), - balloons: z.any(), - tempSelectorId: z.string(), - label: z.string(), - xCoordinate: z.number(), - yCoordinate: z.number(), - anchorX: z.number(), - anchorY: z.number(), - data: z.any(), - description: z.string().nullable().optional() - }), + args: balloonCreateFromPayloadItemValidator, }, annotations: WRITE_ANNOTATIONS, }, withErrorHandling(async (params) => { - const result = await createBallooningBalloonsFromPayload(ctx.client, { ...params.args, companyId: ctx.companyId, createdBy: ctx.userId }); + const result = await createBalloonsFromPayload(ctx.client, { ...params.args, companyId: ctx.companyId, createdBy: ctx.userId }); return toMcpResult(result); - }, "Failed: quality_createBallooningBalloonsFromPayload"), + }, "Failed: quality_createBalloonsFromPayload"), ); server.registerTool( - "quality_updateBallooningBalloons", + "quality_updateBalloons", { - description: "update ballooning balloons", + description: "update balloons", inputSchema: { - args: z.object({ - drawingId: z.string(), - balloons: z.any(), - id: z.string(), - label: z.string().optional(), - xCoordinate: z.number().optional(), - yCoordinate: z.number().optional(), - anchorX: z.number().optional(), - anchorY: z.number().optional(), - data: z.any().optional(), - description: z.string().nullable().optional() - }), + args: balloonUpdateItemValidator, }, annotations: WRITE_ANNOTATIONS, }, withErrorHandling(async (params) => { - const result = await updateBallooningBalloons(ctx.client, { ...params.args, companyId: ctx.companyId, updatedBy: ctx.userId }); + const result = await updateBalloons(ctx.client, { ...params.args, companyId: ctx.companyId, updatedBy: ctx.userId }); return toMcpResult(result); - }, "Failed: quality_updateBallooningBalloons"), + }, "Failed: quality_updateBalloons"), ); server.registerTool( - "quality_deleteBallooningBalloons", + "quality_deleteBalloons", { - description: "delete ballooning balloons", + description: "delete balloons", inputSchema: { - args: z.object({ - drawingId: z.string(), - ids: z.array(z.string()) - }), + args: balloonDeleteValidator, + }, + annotations: DESTRUCTIVE_ANNOTATIONS, + }, + withErrorHandling(async (params) => { + const result = await deleteBalloons(ctx.client, { ...params.args, companyId: ctx.companyId, updatedBy: ctx.userId }); + return toMcpResult(result); + }, "Failed: quality_deleteBalloons"), + ); + + server.registerTool( + "quality_getBalloonAnnotations", + { + description: "get balloon annotations", + inputSchema: { + drawingId: z.string(), + }, + annotations: READ_ONLY_ANNOTATIONS, + }, + withErrorHandling(async (params) => { + const result = await getBalloonAnnotations(ctx.client, params.drawingId); + return toMcpResult(result); + }, "Failed: quality_getBalloonAnnotations"), + ); + + server.registerTool( + "quality_createBalloonAnnotations", + { + description: "create balloon annotations", + inputSchema: { + args: balloonAnnotationCreateItemValidator, + }, + annotations: WRITE_ANNOTATIONS, + }, + withErrorHandling(async (params) => { + const result = await createBalloonAnnotations(ctx.client, { ...params.args, companyId: ctx.companyId, createdBy: ctx.userId }); + return toMcpResult(result); + }, "Failed: quality_createBalloonAnnotations"), + ); + + server.registerTool( + "quality_updateBalloonAnnotations", + { + description: "update balloon annotations", + inputSchema: { + args: balloonAnnotationUpdateItemValidator, + }, + annotations: WRITE_ANNOTATIONS, + }, + withErrorHandling(async (params) => { + const result = await updateBalloonAnnotations(ctx.client, { ...params.args, companyId: ctx.companyId, updatedBy: ctx.userId }); + return toMcpResult(result); + }, "Failed: quality_updateBalloonAnnotations"), + ); + + server.registerTool( + "quality_deleteBalloonAnnotations", + { + description: "delete balloon annotations", + inputSchema: { + args: balloonAnnotationDeleteValidator, }, annotations: DESTRUCTIVE_ANNOTATIONS, }, withErrorHandling(async (params) => { - const result = await deleteBallooningBalloons(ctx.client, { ...params.args, companyId: ctx.companyId, updatedBy: ctx.userId }); + const result = await deleteBalloonAnnotations(ctx.client, { ...params.args, companyId: ctx.companyId, updatedBy: ctx.userId }); return toMcpResult(result); - }, "Failed: quality_deleteBallooningBalloons"), + }, "Failed: quality_deleteBalloonAnnotations"), ); }; diff --git a/apps/erp/app/routes/x+/balloon+/$id.anchor.create.tsx b/apps/erp/app/routes/x+/balloon+/$id.anchor.create.tsx new file mode 100644 index 000000000..6b0137f96 --- /dev/null +++ b/apps/erp/app/routes/x+/balloon+/$id.anchor.create.tsx @@ -0,0 +1,90 @@ +import { assertIsPost } from "@carbon/auth"; +import { requirePermissions } from "@carbon/auth/auth.server"; +import type { ActionFunctionArgs } from "react-router"; +import { data } from "react-router"; +import { createBalloonAnchors } from "~/modules/quality"; + +type AnchorCreateItem = { + pageNumber: number; + xCoordinate: number; + yCoordinate: number; + width: number; + height: number; +}; + +function getErrorMessage(error: unknown, fallback: string) { + if (error instanceof Error) return error.message; + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof (error as { message?: unknown }).message === "string" + ) { + return (error as { message: string }).message; + } + return fallback; +} + +function parseCreateItems( + raw: FormDataEntryValue | null +): AnchorCreateItem[] | null { + if (typeof raw !== "string") return null; + try { + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return null; + return parsed.filter( + (item): item is AnchorCreateItem => + typeof item === "object" && + item !== null && + typeof (item as { pageNumber?: unknown }).pageNumber === "number" && + typeof (item as { xCoordinate?: unknown }).xCoordinate === "number" && + typeof (item as { yCoordinate?: unknown }).yCoordinate === "number" && + typeof (item as { width?: unknown }).width === "number" && + typeof (item as { height?: unknown }).height === "number" + ); + } catch { + return null; + } +} + +export async function action({ request, params }: ActionFunctionArgs) { + assertIsPost(request); + const { client, companyId, userId } = await requirePermissions(request, { + update: "quality" + }); + + const { id } = params; + if (!id) + return data({ success: false, message: "Missing id" }, { status: 400 }); + + const formData = await request.formData(); + const items = parseCreateItems(formData.get("items")); + if (!items) { + return data( + { success: false, message: "Invalid items payload" }, + { status: 400 } + ); + } + + const result = await createBalloonAnchors(client, { + drawingId: id, + companyId, + createdBy: userId, + selectors: items + }); + + if (result.error) { + return data( + { + success: false, + message: getErrorMessage( + result.error, + "Failed to create balloon anchors" + ) + }, + { status: 400 } + ); + } + + return data({ success: true, data: result.data ?? [] }); +} diff --git a/apps/erp/app/routes/x+/balloon+/$id.anchor.delete.tsx b/apps/erp/app/routes/x+/balloon+/$id.anchor.delete.tsx new file mode 100644 index 000000000..e6d4fe153 --- /dev/null +++ b/apps/erp/app/routes/x+/balloon+/$id.anchor.delete.tsx @@ -0,0 +1,73 @@ +import { assertIsPost } from "@carbon/auth"; +import { requirePermissions } from "@carbon/auth/auth.server"; +import type { ActionFunctionArgs } from "react-router"; +import { data } from "react-router"; +import { deleteBalloonAnchors } from "~/modules/quality"; + +function getErrorMessage(error: unknown, fallback: string) { + if (error instanceof Error) return error.message; + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof (error as { message?: unknown }).message === "string" + ) { + return (error as { message: string }).message; + } + return fallback; +} + +function parseIds(raw: FormDataEntryValue | null): string[] | null { + if (typeof raw !== "string") return null; + try { + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return null; + return parsed.filter( + (id): id is string => typeof id === "string" && id.length > 0 + ); + } catch { + return null; + } +} + +export async function action({ request, params }: ActionFunctionArgs) { + assertIsPost(request); + const { client, companyId, userId } = await requirePermissions(request, { + update: "quality" + }); + + const { id } = params; + if (!id) + return data({ success: false, message: "Missing id" }, { status: 400 }); + + const formData = await request.formData(); + const ids = parseIds(formData.get("ids")); + if (!ids) { + return data( + { success: false, message: "Invalid ids payload" }, + { status: 400 } + ); + } + + const result = await deleteBalloonAnchors(client, { + drawingId: id, + companyId, + updatedBy: userId, + ids + }); + + if (result.error) { + return data( + { + success: false, + message: getErrorMessage( + result.error, + "Failed to delete balloon anchors" + ) + }, + { status: 400 } + ); + } + + return data({ success: true, data: result.data ?? [] }); +} diff --git a/apps/erp/app/routes/x+/balloon+/$id.anchor.get.tsx b/apps/erp/app/routes/x+/balloon+/$id.anchor.get.tsx new file mode 100644 index 000000000..68f8f048a --- /dev/null +++ b/apps/erp/app/routes/x+/balloon+/$id.anchor.get.tsx @@ -0,0 +1,43 @@ +import { requirePermissions } from "@carbon/auth/auth.server"; +import type { LoaderFunctionArgs } from "react-router"; +import { data } from "react-router"; +import { getBalloonAnchors } from "~/modules/quality"; + +function getErrorMessage(error: unknown, fallback: string) { + if (error instanceof Error) return error.message; + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof (error as { message?: unknown }).message === "string" + ) { + return (error as { message: string }).message; + } + return fallback; +} + +export async function loader({ request, params }: LoaderFunctionArgs) { + const { client } = await requirePermissions(request, { + view: "quality" + }); + + const { id } = params; + if (!id) + return data({ success: false, message: "Missing id" }, { status: 400 }); + + const result = await getBalloonAnchors(client, id); + if (result.error) { + return data( + { + success: false, + message: getErrorMessage( + result.error, + "Failed to fetch balloon anchors" + ) + }, + { status: 400 } + ); + } + + return data({ success: true, data: result.data ?? [] }); +} diff --git a/apps/erp/app/routes/x+/balloon+/$id.anchor.update.tsx b/apps/erp/app/routes/x+/balloon+/$id.anchor.update.tsx new file mode 100644 index 000000000..bd0083369 --- /dev/null +++ b/apps/erp/app/routes/x+/balloon+/$id.anchor.update.tsx @@ -0,0 +1,87 @@ +import { assertIsPost } from "@carbon/auth"; +import { requirePermissions } from "@carbon/auth/auth.server"; +import type { ActionFunctionArgs } from "react-router"; +import { data } from "react-router"; +import { updateBalloonAnchors } from "~/modules/quality"; + +type AnchorUpdateItem = { + id: string; + pageNumber?: number; + xCoordinate?: number; + yCoordinate?: number; + width?: number; + height?: number; +}; + +function getErrorMessage(error: unknown, fallback: string) { + if (error instanceof Error) return error.message; + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof (error as { message?: unknown }).message === "string" + ) { + return (error as { message: string }).message; + } + return fallback; +} + +function parseUpdateItems( + raw: FormDataEntryValue | null +): AnchorUpdateItem[] | null { + if (typeof raw !== "string") return null; + try { + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return null; + return parsed.filter( + (item): item is AnchorUpdateItem => + typeof item === "object" && + item !== null && + typeof (item as { id?: unknown }).id === "string" + ); + } catch { + return null; + } +} + +export async function action({ request, params }: ActionFunctionArgs) { + assertIsPost(request); + const { client, companyId, userId } = await requirePermissions(request, { + update: "quality" + }); + + const { id } = params; + if (!id) + return data({ success: false, message: "Missing id" }, { status: 400 }); + + const formData = await request.formData(); + const items = parseUpdateItems(formData.get("items")); + if (!items) { + return data( + { success: false, message: "Invalid items payload" }, + { status: 400 } + ); + } + + const result = await updateBalloonAnchors(client, { + drawingId: id, + companyId, + updatedBy: userId, + selectors: items + }); + + if (result.error) { + return data( + { + success: false, + message: getErrorMessage( + result.error, + "Failed to update balloon anchors" + ) + }, + { status: 400 } + ); + } + + return data({ success: true, data: result.data ?? [] }); +} diff --git a/apps/erp/app/routes/x+/balloon+/$id.annotation.create.tsx b/apps/erp/app/routes/x+/balloon+/$id.annotation.create.tsx new file mode 100644 index 000000000..84b7b866a --- /dev/null +++ b/apps/erp/app/routes/x+/balloon+/$id.annotation.create.tsx @@ -0,0 +1,89 @@ +import { assertIsPost } from "@carbon/auth"; +import { requirePermissions } from "@carbon/auth/auth.server"; +import type { ActionFunctionArgs } from "react-router"; +import { data } from "react-router"; +import { createBalloonAnnotations } from "~/modules/quality"; + +type AnnotationCreateItem = { + pageNumber: number; + xCoordinate: number; + yCoordinate: number; + text: string; + width?: number; + height?: number; + rotation?: number; + style?: Record; +}; + +function getErrorMessage(error: unknown, fallback: string) { + if (error instanceof Error) return error.message; + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof (error as { message?: unknown }).message === "string" + ) { + return (error as { message: string }).message; + } + return fallback; +} + +function parseCreateItems( + raw: FormDataEntryValue | null +): AnnotationCreateItem[] | null { + if (typeof raw !== "string") return null; + try { + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return null; + return parsed.filter( + (item): item is AnnotationCreateItem => + typeof item === "object" && + item !== null && + typeof (item as { pageNumber?: unknown }).pageNumber === "number" && + typeof (item as { xCoordinate?: unknown }).xCoordinate === "number" && + typeof (item as { yCoordinate?: unknown }).yCoordinate === "number" && + typeof (item as { text?: unknown }).text === "string" + ); + } catch { + return null; + } +} + +export async function action({ request, params }: ActionFunctionArgs) { + assertIsPost(request); + const { client, companyId, userId } = await requirePermissions(request, { + update: "quality" + }); + + const { id } = params; + if (!id) + return data({ success: false, message: "Missing id" }, { status: 400 }); + + const formData = await request.formData(); + const items = parseCreateItems(formData.get("items")); + if (!items) { + return data( + { success: false, message: "Invalid items payload" }, + { status: 400 } + ); + } + + const result = await createBalloonAnnotations(client, { + drawingId: id, + companyId, + createdBy: userId, + annotations: items + }); + + if (result.error) { + return data( + { + success: false, + message: getErrorMessage(result.error, "Failed to create annotations") + }, + { status: 400 } + ); + } + + return data({ success: true, data: result.data ?? [] }); +} diff --git a/apps/erp/app/routes/x+/balloon+/$id.annotation.delete.tsx b/apps/erp/app/routes/x+/balloon+/$id.annotation.delete.tsx new file mode 100644 index 000000000..23252b72b --- /dev/null +++ b/apps/erp/app/routes/x+/balloon+/$id.annotation.delete.tsx @@ -0,0 +1,70 @@ +import { assertIsPost } from "@carbon/auth"; +import { requirePermissions } from "@carbon/auth/auth.server"; +import type { ActionFunctionArgs } from "react-router"; +import { data } from "react-router"; +import { deleteBalloonAnnotations } from "~/modules/quality"; + +function getErrorMessage(error: unknown, fallback: string) { + if (error instanceof Error) return error.message; + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof (error as { message?: unknown }).message === "string" + ) { + return (error as { message: string }).message; + } + return fallback; +} + +function parseIds(raw: FormDataEntryValue | null): string[] | null { + if (typeof raw !== "string") return null; + try { + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return null; + return parsed.filter( + (id): id is string => typeof id === "string" && id.length > 0 + ); + } catch { + return null; + } +} + +export async function action({ request, params }: ActionFunctionArgs) { + assertIsPost(request); + const { client, companyId, userId } = await requirePermissions(request, { + update: "quality" + }); + + const { id } = params; + if (!id) + return data({ success: false, message: "Missing id" }, { status: 400 }); + + const formData = await request.formData(); + const ids = parseIds(formData.get("ids")); + if (!ids) { + return data( + { success: false, message: "Invalid ids payload" }, + { status: 400 } + ); + } + + const result = await deleteBalloonAnnotations(client, { + drawingId: id, + companyId, + updatedBy: userId, + ids + }); + + if (result.error) { + return data( + { + success: false, + message: getErrorMessage(result.error, "Failed to delete annotations") + }, + { status: 400 } + ); + } + + return data({ success: true, data: result.data ?? [] }); +} diff --git a/apps/erp/app/routes/x+/balloon+/$id.annotation.get.tsx b/apps/erp/app/routes/x+/balloon+/$id.annotation.get.tsx new file mode 100644 index 000000000..9c06d335c --- /dev/null +++ b/apps/erp/app/routes/x+/balloon+/$id.annotation.get.tsx @@ -0,0 +1,40 @@ +import { requirePermissions } from "@carbon/auth/auth.server"; +import type { LoaderFunctionArgs } from "react-router"; +import { data } from "react-router"; +import { getBalloonAnnotations } from "~/modules/quality"; + +function getErrorMessage(error: unknown, fallback: string) { + if (error instanceof Error) return error.message; + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof (error as { message?: unknown }).message === "string" + ) { + return (error as { message: string }).message; + } + return fallback; +} + +export async function loader({ request, params }: LoaderFunctionArgs) { + const { client } = await requirePermissions(request, { + view: "quality" + }); + + const { id } = params; + if (!id) + return data({ success: false, message: "Missing id" }, { status: 400 }); + + const result = await getBalloonAnnotations(client, id); + if (result.error) { + return data( + { + success: false, + message: getErrorMessage(result.error, "Failed to fetch annotations") + }, + { status: 400 } + ); + } + + return data({ success: true, data: result.data ?? [] }); +} diff --git a/apps/erp/app/routes/x+/balloon+/$id.annotation.update.tsx b/apps/erp/app/routes/x+/balloon+/$id.annotation.update.tsx new file mode 100644 index 000000000..a7537278e --- /dev/null +++ b/apps/erp/app/routes/x+/balloon+/$id.annotation.update.tsx @@ -0,0 +1,87 @@ +import { assertIsPost } from "@carbon/auth"; +import { requirePermissions } from "@carbon/auth/auth.server"; +import type { ActionFunctionArgs } from "react-router"; +import { data } from "react-router"; +import { updateBalloonAnnotations } from "~/modules/quality"; + +type AnnotationUpdateItem = { + id: string; + pageNumber?: number; + xCoordinate?: number; + yCoordinate?: number; + text?: string; + width?: number | null; + height?: number | null; + rotation?: number; + style?: Record | null; +}; + +function getErrorMessage(error: unknown, fallback: string) { + if (error instanceof Error) return error.message; + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof (error as { message?: unknown }).message === "string" + ) { + return (error as { message: string }).message; + } + return fallback; +} + +function parseUpdateItems( + raw: FormDataEntryValue | null +): AnnotationUpdateItem[] | null { + if (typeof raw !== "string") return null; + try { + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return null; + return parsed.filter( + (item): item is AnnotationUpdateItem => + typeof item === "object" && + item !== null && + typeof (item as { id?: unknown }).id === "string" + ); + } catch { + return null; + } +} + +export async function action({ request, params }: ActionFunctionArgs) { + assertIsPost(request); + const { client, companyId, userId } = await requirePermissions(request, { + update: "quality" + }); + + const { id } = params; + if (!id) + return data({ success: false, message: "Missing id" }, { status: 400 }); + + const formData = await request.formData(); + const items = parseUpdateItems(formData.get("items")); + if (!items) { + return data( + { success: false, message: "Invalid items payload" }, + { status: 400 } + ); + } + + const result = await updateBalloonAnnotations(client, { + drawingId: id, + companyId, + updatedBy: userId, + annotations: items + }); + + if (result.error) { + return data( + { + success: false, + message: getErrorMessage(result.error, "Failed to update annotations") + }, + { status: 400 } + ); + } + + return data({ success: true, data: result.data ?? [] }); +} diff --git a/apps/erp/app/routes/x+/balloon+/$id.balloon.create.tsx b/apps/erp/app/routes/x+/balloon+/$id.balloon.create.tsx new file mode 100644 index 000000000..0fa3299e0 --- /dev/null +++ b/apps/erp/app/routes/x+/balloon+/$id.balloon.create.tsx @@ -0,0 +1,108 @@ +import { assertIsPost } from "@carbon/auth"; +import { requirePermissions } from "@carbon/auth/auth.server"; +import type { ActionFunctionArgs } from "react-router"; +import { data } from "react-router"; +import { createBalloonsFromPayload } from "~/modules/quality"; + +type BalloonCreateItem = { + selectorId: string; + label: string; + xCoordinate: number; + yCoordinate: number; + anchorX: number; + anchorY: number; + data: Record; + description?: string | null; +}; + +function getErrorMessage(error: unknown, fallback: string) { + if (error instanceof Error) return error.message; + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof (error as { message?: unknown }).message === "string" + ) { + return (error as { message: string }).message; + } + return fallback; +} + +function parseCreateItems( + raw: FormDataEntryValue | null +): BalloonCreateItem[] | null { + if (typeof raw !== "string") return null; + try { + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return null; + return parsed.filter( + (item): item is BalloonCreateItem => + typeof item === "object" && + item !== null && + typeof (item as { selectorId?: unknown }).selectorId === "string" && + typeof (item as { label?: unknown }).label === "string" && + typeof (item as { xCoordinate?: unknown }).xCoordinate === "number" && + typeof (item as { yCoordinate?: unknown }).yCoordinate === "number" && + typeof (item as { anchorX?: unknown }).anchorX === "number" && + typeof (item as { anchorY?: unknown }).anchorY === "number" && + typeof (item as { data?: unknown }).data === "object" && + (item as { data?: unknown }).data !== null && + !Array.isArray((item as { data?: unknown }).data) + ); + } catch { + return null; + } +} + +export async function action({ request, params }: ActionFunctionArgs) { + assertIsPost(request); + const { client, companyId, userId } = await requirePermissions(request, { + update: "quality" + }); + + const { id } = params; + if (!id) + return data({ success: false, message: "Missing id" }, { status: 400 }); + + const formData = await request.formData(); + const items = parseCreateItems(formData.get("items")); + if (!items) { + return data( + { success: false, message: "Invalid items payload" }, + { status: 400 } + ); + } + + const selectorIdMap = Object.fromEntries( + items.map((item) => [item.selectorId, item.selectorId]) + ); + + const result = await createBalloonsFromPayload(client, { + drawingId: id, + companyId, + createdBy: userId, + selectorIdMap, + balloons: items.map((item) => ({ + tempSelectorId: item.selectorId, + label: item.label, + xCoordinate: item.xCoordinate, + yCoordinate: item.yCoordinate, + anchorX: item.anchorX, + anchorY: item.anchorY, + data: item.data, + description: item.description ?? null + })) + }); + + if (result.error) { + return data( + { + success: false, + message: getErrorMessage(result.error, "Failed to create balloons") + }, + { status: 400 } + ); + } + + return data({ success: true, data: result.data ?? [] }); +} diff --git a/apps/erp/app/routes/x+/balloon+/$id.balloon.delete.tsx b/apps/erp/app/routes/x+/balloon+/$id.balloon.delete.tsx new file mode 100644 index 000000000..00bf49318 --- /dev/null +++ b/apps/erp/app/routes/x+/balloon+/$id.balloon.delete.tsx @@ -0,0 +1,70 @@ +import { assertIsPost } from "@carbon/auth"; +import { requirePermissions } from "@carbon/auth/auth.server"; +import type { ActionFunctionArgs } from "react-router"; +import { data } from "react-router"; +import { deleteBalloons } from "~/modules/quality"; + +function getErrorMessage(error: unknown, fallback: string) { + if (error instanceof Error) return error.message; + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof (error as { message?: unknown }).message === "string" + ) { + return (error as { message: string }).message; + } + return fallback; +} + +function parseIds(raw: FormDataEntryValue | null): string[] | null { + if (typeof raw !== "string") return null; + try { + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return null; + return parsed.filter( + (id): id is string => typeof id === "string" && id.length > 0 + ); + } catch { + return null; + } +} + +export async function action({ request, params }: ActionFunctionArgs) { + assertIsPost(request); + const { client, companyId, userId } = await requirePermissions(request, { + update: "quality" + }); + + const { id } = params; + if (!id) + return data({ success: false, message: "Missing id" }, { status: 400 }); + + const formData = await request.formData(); + const ids = parseIds(formData.get("ids")); + if (!ids) { + return data( + { success: false, message: "Invalid ids payload" }, + { status: 400 } + ); + } + + const result = await deleteBalloons(client, { + drawingId: id, + companyId, + updatedBy: userId, + ids + }); + + if (result.error) { + return data( + { + success: false, + message: getErrorMessage(result.error, "Failed to delete balloons") + }, + { status: 400 } + ); + } + + return data({ success: true, data: result.data ?? [] }); +} diff --git a/apps/erp/app/routes/x+/balloon+/$id.balloon.get.tsx b/apps/erp/app/routes/x+/balloon+/$id.balloon.get.tsx new file mode 100644 index 000000000..1412ef9e7 --- /dev/null +++ b/apps/erp/app/routes/x+/balloon+/$id.balloon.get.tsx @@ -0,0 +1,40 @@ +import { requirePermissions } from "@carbon/auth/auth.server"; +import type { LoaderFunctionArgs } from "react-router"; +import { data } from "react-router"; +import { getBalloons } from "~/modules/quality"; + +function getErrorMessage(error: unknown, fallback: string) { + if (error instanceof Error) return error.message; + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof (error as { message?: unknown }).message === "string" + ) { + return (error as { message: string }).message; + } + return fallback; +} + +export async function loader({ request, params }: LoaderFunctionArgs) { + const { client } = await requirePermissions(request, { + view: "quality" + }); + + const { id } = params; + if (!id) + return data({ success: false, message: "Missing id" }, { status: 400 }); + + const result = await getBalloons(client, id); + if (result.error) { + return data( + { + success: false, + message: getErrorMessage(result.error, "Failed to fetch balloons") + }, + { status: 400 } + ); + } + + return data({ success: true, data: result.data ?? [] }); +} diff --git a/apps/erp/app/routes/x+/balloon+/$id.balloon.update.tsx b/apps/erp/app/routes/x+/balloon+/$id.balloon.update.tsx new file mode 100644 index 000000000..cf304b60e --- /dev/null +++ b/apps/erp/app/routes/x+/balloon+/$id.balloon.update.tsx @@ -0,0 +1,86 @@ +import { assertIsPost } from "@carbon/auth"; +import { requirePermissions } from "@carbon/auth/auth.server"; +import type { ActionFunctionArgs } from "react-router"; +import { data } from "react-router"; +import { updateBalloons } from "~/modules/quality"; + +type BalloonUpdateItem = { + id: string; + label?: string; + xCoordinate?: number; + yCoordinate?: number; + anchorX?: number; + anchorY?: number; + data?: Record; + description?: string | null; +}; + +function getErrorMessage(error: unknown, fallback: string) { + if (error instanceof Error) return error.message; + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof (error as { message?: unknown }).message === "string" + ) { + return (error as { message: string }).message; + } + return fallback; +} + +function parseUpdateItems( + raw: FormDataEntryValue | null +): BalloonUpdateItem[] | null { + if (typeof raw !== "string") return null; + try { + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return null; + return parsed.filter( + (item): item is BalloonUpdateItem => + typeof item === "object" && + item !== null && + typeof (item as { id?: unknown }).id === "string" + ); + } catch { + return null; + } +} + +export async function action({ request, params }: ActionFunctionArgs) { + assertIsPost(request); + const { client, companyId, userId } = await requirePermissions(request, { + update: "quality" + }); + + const { id } = params; + if (!id) + return data({ success: false, message: "Missing id" }, { status: 400 }); + + const formData = await request.formData(); + const items = parseUpdateItems(formData.get("items")); + if (!items) { + return data( + { success: false, message: "Invalid items payload" }, + { status: 400 } + ); + } + + const result = await updateBalloons(client, { + drawingId: id, + companyId, + updatedBy: userId, + balloons: items + }); + + if (result.error) { + return data( + { + success: false, + message: getErrorMessage(result.error, "Failed to update balloons") + }, + { status: 400 } + ); + } + + return data({ success: true, data: result.data ?? [] }); +} diff --git a/apps/erp/app/routes/x+/ballooning-diagram+/delete.$id.tsx b/apps/erp/app/routes/x+/balloon+/$id.delete.tsx similarity index 68% rename from apps/erp/app/routes/x+/ballooning-diagram+/delete.$id.tsx rename to apps/erp/app/routes/x+/balloon+/$id.delete.tsx index 5b320f898..ac38b19f0 100644 --- a/apps/erp/app/routes/x+/ballooning-diagram+/delete.$id.tsx +++ b/apps/erp/app/routes/x+/balloon+/$id.delete.tsx @@ -3,7 +3,7 @@ import { requirePermissions } from "@carbon/auth/auth.server"; import { flash } from "@carbon/auth/session.server"; import type { ActionFunctionArgs } from "react-router"; import { redirect } from "react-router"; -import { deleteBallooningDiagram } from "~/modules/quality"; +import { deleteBalloonDocument } from "~/modules/quality"; import { path } from "~/utils/path"; export async function action({ request, params }: ActionFunctionArgs) { @@ -15,20 +15,20 @@ export async function action({ request, params }: ActionFunctionArgs) { const { id } = params; if (!id) throw new Error("Could not find id"); - const result = await deleteBallooningDiagram(client, id); + const result = await deleteBalloonDocument(client, id); if (result.error) { throw redirect( - path.to.ballooningDiagrams, + path.to.balloonDocuments, await flash( request, - error(result.error, "Failed to delete ballooning diagram") + error(result.error, "Failed to delete balloon document") ) ); } throw redirect( - path.to.ballooningDiagrams, - await flash(request, success("Ballooning diagram deleted")) + path.to.balloonDocuments, + await flash(request, success("Balloon document deleted")) ); } diff --git a/apps/erp/app/routes/x+/ballooning-diagram+/$id.save.tsx b/apps/erp/app/routes/x+/balloon+/$id.save.tsx similarity index 89% rename from apps/erp/app/routes/x+/ballooning-diagram+/$id.save.tsx rename to apps/erp/app/routes/x+/balloon+/$id.save.tsx index 47318e795..e307a3c3a 100644 --- a/apps/erp/app/routes/x+/ballooning-diagram+/$id.save.tsx +++ b/apps/erp/app/routes/x+/balloon+/$id.save.tsx @@ -3,18 +3,31 @@ import { requirePermissions } from "@carbon/auth/auth.server"; import type { ActionFunctionArgs } from "react-router"; import { data } from "react-router"; import { - createBallooningBalloonsFromPayload, - createBallooningSelectors, - createBalloonsForSelectors, - deleteBallooningBalloons, - deleteBallooningSelectors, - getBallooningBalloons, - getBallooningSelectors, - updateBallooningBalloons, - updateBallooningSelectors, - upsertBallooningDiagram + createBalloonAnchors, + createBalloonsForAnchors, + createBalloonsFromPayload, + deleteBalloonAnchors, + deleteBalloons, + getBalloonAnchors, + getBalloons, + updateBalloonAnchors, + updateBalloons, + upsertBalloonDocument } from "~/modules/quality"; +function getErrorMessage(error: unknown, fallback: string) { + if (error instanceof Error) return error.message; + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof (error as { message?: unknown }).message === "string" + ) { + return (error as { message: string }).message; + } + return fallback; +} + export async function action({ request, params }: ActionFunctionArgs) { assertIsPost(request); const { client, userId, companyId } = await requirePermissions(request, { @@ -47,7 +60,7 @@ export async function action({ request, params }: ActionFunctionArgs) { ? Number(defaultPageHeightRaw) : undefined; - const result = await upsertBallooningDiagram(client, { + const result = await upsertBalloonDocument(client, { id, name, pdfUrl: pdfUrl ?? undefined, @@ -61,7 +74,13 @@ export async function action({ request, params }: ActionFunctionArgs) { if (result.error) { return data( - { success: false, message: result.error.message }, + { + success: false, + message: getErrorMessage( + result.error, + "Failed to save balloon document" + ) + }, { status: 400 } ); } @@ -189,7 +208,7 @@ export async function action({ request, params }: ActionFunctionArgs) { } if (balloonDeleteIds.length > 0) { - const delBalloons = await deleteBallooningBalloons(client, { + const delBalloons = await deleteBalloons(client, { drawingId: id, companyId, updatedBy: userId, @@ -291,7 +310,7 @@ export async function action({ request, params }: ActionFunctionArgs) { } if (selectorDeleteIds.length > 0) { - const delSelectors = await deleteBallooningSelectors(client, { + const delSelectors = await deleteBalloonAnchors(client, { drawingId: id, companyId, updatedBy: userId, @@ -305,7 +324,7 @@ export async function action({ request, params }: ActionFunctionArgs) { } } - const createSelectorsResult = await createBallooningSelectors(client, { + const createSelectorsResult = await createBalloonAnchors(client, { drawingId: id, companyId, createdBy: userId, @@ -343,7 +362,7 @@ export async function action({ request, params }: ActionFunctionArgs) { } if (balloonsParsed?.create?.length) { - const fromPayload = await createBallooningBalloonsFromPayload(client, { + const fromPayload = await createBalloonsFromPayload(client, { drawingId: id, companyId, createdBy: userId, @@ -358,9 +377,7 @@ export async function action({ request, params }: ActionFunctionArgs) { data: b.data, description: b.description ?? - (typeof b.data["featureName"] === "string" - ? b.data["featureName"] - : null) + (typeof b.data.featureName === "string" ? b.data.featureName : null) })) }); @@ -371,7 +388,7 @@ export async function action({ request, params }: ActionFunctionArgs) { ); } } else if (insertedSelectors.length > 0) { - const createBalloonsResult = await createBalloonsForSelectors(client, { + const createBalloonsResult = await createBalloonsForAnchors(client, { drawingId: id, companyId, createdBy: userId, @@ -386,7 +403,7 @@ export async function action({ request, params }: ActionFunctionArgs) { } } - const updateSelectorsResult = await updateBallooningSelectors(client, { + const updateSelectorsResult = await updateBalloonAnchors(client, { drawingId: id, companyId, updatedBy: userId, @@ -402,7 +419,7 @@ export async function action({ request, params }: ActionFunctionArgs) { } if (balloonsParsed?.update?.length) { - const updateBalloonsResult = await updateBallooningBalloons(client, { + const updateBalloonsResult = await updateBalloons(client, { drawingId: id, companyId, updatedBy: userId, @@ -418,15 +435,15 @@ export async function action({ request, params }: ActionFunctionArgs) { } const [selectorsResult, balloonsResult] = await Promise.all([ - getBallooningSelectors(client, id), - getBallooningBalloons(client, id) + getBalloonAnchors(client, id), + getBalloons(client, id) ]); if (selectorsResult.error || balloonsResult.error) { return data( { success: false, - message: "Saved but failed to reload persisted ballooning data" + message: "Saved but failed to reload persisted balloon document data" }, { status: 500 } ); diff --git a/apps/erp/app/routes/x+/ballooning-diagram+/$id.tsx b/apps/erp/app/routes/x+/balloon+/$id.tsx similarity index 67% rename from apps/erp/app/routes/x+/ballooning-diagram+/$id.tsx rename to apps/erp/app/routes/x+/balloon+/$id.tsx index cbc71e876..375278592 100644 --- a/apps/erp/app/routes/x+/ballooning-diagram+/$id.tsx +++ b/apps/erp/app/routes/x+/balloon+/$id.tsx @@ -8,22 +8,32 @@ import { lazy, Suspense } from "react"; import type { LoaderFunctionArgs } from "react-router"; import { redirect, useLoaderData } from "react-router"; import { - getBallooningBalloons, - getBallooningDiagram, - getBallooningSelectors + getBalloonAnchors, + getBalloonDocument, + getBalloons } from "~/modules/quality"; -import type { BallooningDiagramContent } from "~/modules/quality/types"; +import type { BalloonDocumentContent } from "~/modules/quality/types"; import type { Handle } from "~/utils/handle"; import { path } from "~/utils/path"; -/** Konva must not load on the server (it requires native `canvas`). */ -const BalloonDiagramEditor = lazy( - () => import("~/modules/quality/ui/Ballooning/BalloonDiagramEditor") +const BalloonDocumentEditor = lazy( + () => import("~/modules/quality/ui/BalloonDocument/BalloonDocumentEditor") ); export const handle: Handle = { - breadcrumb: msg`Ballooning Diagrams`, - to: path.to.ballooningDiagrams, + breadcrumb: ( + _params: unknown, + data?: { + diagram?: { + name?: string | null; + content?: { drawingNumber?: string | null } | null; + }; + } + ) => + data?.diagram?.name ?? + data?.diagram?.content?.drawingNumber ?? + msg`Balloon Document`, + to: path.to.balloonDocuments, module: "quality" }; @@ -37,23 +47,27 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const serviceRole = await getCarbonServiceRole(); const [diagram, selectors, balloons] = await Promise.all([ - getBallooningDiagram(serviceRole, id), - getBallooningSelectors(serviceRole, id), - getBallooningBalloons(serviceRole, id) + getBalloonDocument(serviceRole, id), + getBalloonAnchors(serviceRole, id), + getBalloons(serviceRole, id) ]); if (diagram.error) { throw redirect( - path.to.ballooningDiagrams, + path.to.balloonDocuments, await flash( request, - error(diagram.error, "Failed to load ballooning diagram") + error(diagram.error, "Failed to load balloon document") ) ); } + if (!diagram.data) { + throw redirect(path.to.balloonDocuments); + } + if (diagram.data.companyId !== companyId) { - throw redirect(path.to.ballooningDiagrams); + throw redirect(path.to.balloonDocuments); } return { @@ -63,9 +77,9 @@ export async function loader({ request, params }: LoaderFunctionArgs) { }; } -export default function BallooningDetailRoute() { +export default function BalloonDetailRoute() { const { diagram, selectors, balloons } = useLoaderData(); - const content = diagram.content as BallooningDiagramContent | null; + const content = diagram.content as BalloonDocumentContent | null; return (
@@ -84,7 +98,7 @@ export default function BallooningDetailRoute() {
} > - (); + const { pathname } = useLocation(); + const basePath = path.to.balloonDocuments; + const suffix = pathname.startsWith(`${basePath}/`) + ? pathname.slice(basePath.length + 1) + : ""; + const isDocumentDetail = suffix.length > 0 && !suffix.includes("/"); + + if (isDocumentDetail) { + return ; + } return ( - + ); diff --git a/apps/erp/app/routes/x+/quality+/ballooning.new.tsx b/apps/erp/app/routes/x+/balloon+/new.tsx similarity index 67% rename from apps/erp/app/routes/x+/quality+/ballooning.new.tsx rename to apps/erp/app/routes/x+/balloon+/new.tsx index c4eca938c..84ec10144 100644 --- a/apps/erp/app/routes/x+/quality+/ballooning.new.tsx +++ b/apps/erp/app/routes/x+/balloon+/new.tsx @@ -4,9 +4,9 @@ import { flash } from "@carbon/auth/session.server"; import { validationError, validator } from "@carbon/form"; import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; import { redirect, useNavigate } from "react-router"; -import { upsertBallooningDiagram } from "~/modules/quality"; -import { ballooningDiagramValidator } from "~/modules/quality/quality.models"; -import { BallooningForm } from "~/modules/quality/ui/Ballooning"; +import { upsertBalloonDocument } from "~/modules/quality"; +import { balloonDocumentValidator } from "~/modules/quality/quality.models"; +import { BalloonDocumentForm } from "~/modules/quality/ui/BalloonDocument"; import { path } from "~/utils/path"; export async function loader({ request }: LoaderFunctionArgs) { @@ -21,7 +21,7 @@ export async function action({ request }: ActionFunctionArgs) { }); const formData = await request.formData(); - const validation = await validator(ballooningDiagramValidator).validate( + const validation = await validator(balloonDocumentValidator).validate( formData ); @@ -29,7 +29,7 @@ export async function action({ request }: ActionFunctionArgs) { return validationError(validation.error); } - const result = await upsertBallooningDiagram(client, { + const result = await upsertBalloonDocument(client, { ...validation.data, companyId, createdBy: userId @@ -37,25 +37,25 @@ export async function action({ request }: ActionFunctionArgs) { if (result.error || !result.data?.id) { throw redirect( - path.to.ballooningDiagrams, + path.to.balloonDocuments, await flash( request, - error(result.error, "Failed to create ballooning diagram") + error(result.error, "Failed to create balloon document") ) ); } throw redirect( - path.to.ballooningDiagram(result.data.id), - await flash(request, success("Ballooning diagram created")) + path.to.balloonDocument(result.data.id), + await flash(request, success("Balloon document created")) ); } -export default function BallooningNewRoute() { +export default function BalloonNewRoute() { const navigate = useNavigate(); return ( - navigate(-1)} /> diff --git a/apps/erp/app/routes/x+/ballooning-diagram+/_layout.tsx b/apps/erp/app/routes/x+/ballooning-diagram+/_layout.tsx deleted file mode 100644 index e1741e738..000000000 --- a/apps/erp/app/routes/x+/ballooning-diagram+/_layout.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { msg } from "@lingui/core/macro"; -import type { MetaFunction } from "react-router"; -import { Outlet } from "react-router"; -import type { Handle } from "~/utils/handle"; -import { path } from "~/utils/path"; - -export const meta: MetaFunction = () => { - return [{ title: "Carbon | Ballooning Diagram" }]; -}; - -export const handle: Handle = { - breadcrumb: msg`Quality`, - to: path.to.quality, - module: "quality" -}; - -export default function BallooningDiagramLayout() { - return ; -} diff --git a/apps/erp/app/utils/path.ts b/apps/erp/app/utils/path.ts index 3550c8029..1aa6eb1fb 100644 --- a/apps/erp/app/utils/path.ts +++ b/apps/erp/app/utils/path.ts @@ -808,12 +808,13 @@ export const path = { generatePath(`${x}/resources/failure-modes/${id}`), failureModes: `${x}/resources/failure-modes`, fiscalYears: `${x}/accounting/years`, - ballooningDiagram: (id: string) => - generatePath(`${x}/ballooning-diagram/${id}`), - ballooningDiagrams: `${x}/quality/ballooning`, - deleteBallooningDiagram: (id: string) => - generatePath(`${x}/ballooning-diagram/delete/${id}`), - newBallooningDiagram: `${x}/quality/ballooning/new`, + balloonDocument: (id: string) => generatePath(`${x}/balloon/${id}`), + balloonDocuments: `${x}/balloon`, + deleteBalloonDocument: (id: string) => + generatePath(`${x}/balloon/${id}/delete`), + newBalloonDocument: `${x}/balloon/new`, + saveBalloonDocument: (id: string) => + generatePath(`${x}/balloon/${id}/save`), gauge: (id: string) => generatePath(`${x}/quality/gauges/${id}`), gauges: `${x}/quality/gauges`, gaugeCalibrationRecord: (id: string) => diff --git a/llm/cache/mcp-tools-reference.md b/llm/cache/mcp-tools-reference.md index a1fa0d101..75fdd3be0 100644 --- a/llm/cache/mcp-tools-reference.md +++ b/llm/cache/mcp-tools-reference.md @@ -1,7 +1,7 @@ # Carbon ERP MCP Tools Reference > Auto-generated by scripts/generate-mcp.ts -> Total: 1030 tools across 15 modules +> Total: 1034 tools across 15 modules ## account (10 tools) @@ -4179,7 +4179,7 @@ get purchasing r f q suppliers with links --- -## quality (81 tools) +## quality (85 tools) ### quality_activateGauge (WRITE) activate gauge @@ -4653,28 +4653,29 @@ upsert risk updatedBy: string; // This might be used for history/tracking if added }) -### quality_getBallooningDiagrams (READ) -get ballooning diagrams +### quality_getBalloonDocuments (READ) +get balloon documents **Parameters:** - `args`: { search: string | null } & GenericQueryFilters (optional) -### quality_getBallooningDiagram (READ) -get ballooning diagram +### quality_getBalloonDocument (READ) +get balloon document **Parameters:** - `id`: string -### quality_upsertBallooningDiagram (WRITE) -upsert ballooning diagram +### quality_upsertBalloonDocument (WRITE) +upsert balloon document **Parameters:** -- `diagram`: | (Omit, "id"> & { +- `diagram`: | (Omit, "id"> & { id?: undefined; companyId: string; createdBy: string; + updatedBy?: string; pageCount?: number; defaultPageWidth?: number; defaultPageHeight?: number; }) - | (Omit, "id"> & { + | (Omit, "id"> & { id: string; companyId?: string; createdBy: string; @@ -4684,66 +4685,53 @@ upsert ballooning diagram defaultPageHeight?: number; }) -### quality_deleteBallooningDiagram (DESTRUCTIVE) -delete ballooning diagram +### quality_deleteBalloonDocument (DESTRUCTIVE) +delete balloon document **Parameters:** - `id`: string -### quality_getBallooningSelectors (READ) -get ballooning selectors +### quality_getBalloonAnchors (READ) +get balloon anchors **Parameters:** - `drawingId`: string -### quality_createBallooningSelectors (WRITE) -create ballooning selectors +### quality_createBalloonAnchors (WRITE) +create balloon anchors **Parameters:** - `args`: { drawingId: string; companyId: string; createdBy: string; - selectors: { - pageNumber: number; - xCoordinate: number; - yCoordinate: number; - width: number; - height: number; - }[]; + selectors: z.infer[]; } -### quality_updateBallooningSelectors (WRITE) -update ballooning selectors +### quality_updateBalloonAnchors (WRITE) +update balloon anchors **Parameters:** - `args`: { drawingId: string; companyId: string; updatedBy: string; - selectors: { - id: string; - pageNumber?: number; - xCoordinate?: number; - yCoordinate?: number; - width?: number; - height?: number; - }[]; + selectors: z.infer[]; } -### quality_deleteBallooningSelectors (DESTRUCTIVE) -delete ballooning selectors +### quality_deleteBalloonAnchors (DESTRUCTIVE) +delete balloon anchors **Parameters:** - `args`: { drawingId: string; companyId: string; updatedBy: string; - ids: string[]; + ids: z.infer["ids"]; } -### quality_getBallooningBalloons (READ) -get ballooning balloons +### quality_getBalloons (READ) +get balloons **Parameters:** - `drawingId`: string -### quality_createBalloonsForSelectors (WRITE) -create balloons for selectors +### quality_createBalloonsForAnchors (WRITE) +create balloons for anchors **Parameters:** - `args`: { drawingId: string; @@ -4759,53 +4747,70 @@ create balloons for selectors }[]; } -### quality_createBallooningBalloonsFromPayload (WRITE) -create ballooning balloons from payload +### quality_createBalloonsFromPayload (WRITE) +create balloons from payload **Parameters:** - `args`: { drawingId: string; companyId: string; createdBy: string; selectorIdMap: Record; - balloons: Array<{ - tempSelectorId: string; - label: string; - xCoordinate: number; - yCoordinate: number; - anchorX: number; - anchorY: number; - data: Record; - description?: string | null; - }>; + balloons: z.infer[]; } -### quality_updateBallooningBalloons (WRITE) -update ballooning balloons +### quality_updateBalloons (WRITE) +update balloons **Parameters:** - `args`: { drawingId: string; companyId: string; updatedBy: string; - balloons: Array<{ - id: string; - label?: string; - xCoordinate?: number; - yCoordinate?: number; - anchorX?: number; - anchorY?: number; - data?: Record; - description?: string | null; - }>; + balloons: z.infer[]; + } + +### quality_deleteBalloons (DESTRUCTIVE) +delete balloons +**Parameters:** +- `args`: { + drawingId: string; + companyId: string; + updatedBy: string; + ids: z.infer["ids"]; + } + +### quality_getBalloonAnnotations (READ) +get balloon annotations +**Parameters:** +- `drawingId`: string + +### quality_createBalloonAnnotations (WRITE) +create balloon annotations +**Parameters:** +- `args`: { + drawingId: string; + companyId: string; + createdBy: string; + annotations: z.infer[]; + } + +### quality_updateBalloonAnnotations (WRITE) +update balloon annotations +**Parameters:** +- `args`: { + drawingId: string; + companyId: string; + updatedBy: string; + annotations: z.infer[]; } -### quality_deleteBallooningBalloons (DESTRUCTIVE) -delete ballooning balloons +### quality_deleteBalloonAnnotations (DESTRUCTIVE) +delete balloon annotations **Parameters:** - `args`: { drawingId: string; companyId: string; updatedBy: string; - ids: string[]; + ids: z.infer["ids"]; } --- diff --git a/packages/database/supabase/migrations/20260421120000_ballooning-tables.sql b/packages/database/supabase/migrations/20260421120000_balloon-document-tables.sql similarity index 54% rename from packages/database/supabase/migrations/20260421120000_ballooning-tables.sql rename to packages/database/supabase/migrations/20260421120000_balloon-document-tables.sql index 6ed1dba07..286682971 100644 --- a/packages/database/supabase/migrations/20260421120000_ballooning-tables.sql +++ b/packages/database/supabase/migrations/20260421120000_balloon-document-tables.sql @@ -1,9 +1,9 @@ --- Ballooning tables --- - Uses "ballooningDrawing" as parent entity --- - "ballooningBalloon" derives page from linked selector (no pageNumber column) +-- Balloon document tables +-- - Uses "balloonDocument" as parent entity +-- - "balloon" derives page from linked selector (no pageNumber column) -- - Enforces tenant consistency with composite (id, companyId) foreign keys -CREATE TABLE "ballooningDrawing" ( +CREATE TABLE "balloonDocument" ( "id" TEXT NOT NULL DEFAULT id('bdr'), "companyId" TEXT NOT NULL, "qualityDocumentId" TEXT NOT NULL, @@ -22,26 +22,26 @@ CREATE TABLE "ballooningDrawing" ( "updatedBy" TEXT, "updatedAt" TIMESTAMP WITH TIME ZONE, - CONSTRAINT "ballooningDrawing_pkey" PRIMARY KEY ("id", "companyId"), - CONSTRAINT "ballooningDrawing_id_unique" UNIQUE ("id"), - CONSTRAINT "ballooningDrawing_version_check" CHECK ("version" >= 0), - CONSTRAINT "ballooningDrawing_pageCount_check" CHECK ("pageCount" > 0), - CONSTRAINT "ballooningDrawing_defaultPageWidth_check" CHECK ("defaultPageWidth" > 0), - CONSTRAINT "ballooningDrawing_defaultPageHeight_check" CHECK ("defaultPageHeight" > 0), + CONSTRAINT "balloonDocument_pkey" PRIMARY KEY ("id", "companyId"), + CONSTRAINT "balloonDocument_id_unique" UNIQUE ("id"), + CONSTRAINT "balloonDocument_version_check" CHECK ("version" >= 0), + CONSTRAINT "balloonDocument_pageCount_check" CHECK ("pageCount" > 0), + CONSTRAINT "balloonDocument_defaultPageWidth_check" CHECK ("defaultPageWidth" > 0), + CONSTRAINT "balloonDocument_defaultPageHeight_check" CHECK ("defaultPageHeight" > 0), - CONSTRAINT "ballooningDrawing_companyId_fkey" + CONSTRAINT "balloonDocument_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "company"("id") ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT "ballooningDrawing_qualityDocumentId_fkey" + CONSTRAINT "balloonDocument_qualityDocumentId_fkey" FOREIGN KEY ("qualityDocumentId") REFERENCES "qualityDocument"("id") ON DELETE RESTRICT ON UPDATE CASCADE, - CONSTRAINT "ballooningDrawing_uploadedBy_fkey" + CONSTRAINT "balloonDocument_uploadedBy_fkey" FOREIGN KEY ("uploadedBy") REFERENCES "user"("id") ON UPDATE CASCADE, - CONSTRAINT "ballooningDrawing_createdBy_fkey" + CONSTRAINT "balloonDocument_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "user"("id") ON UPDATE CASCADE, - CONSTRAINT "ballooningDrawing_updatedBy_fkey" + CONSTRAINT "balloonDocument_updatedBy_fkey" FOREIGN KEY ("updatedBy") REFERENCES "user"("id") ON UPDATE CASCADE ); -CREATE TABLE "ballooningSelector" ( +CREATE TABLE "balloonAnchor" ( "id" TEXT NOT NULL DEFAULT id('bsl'), "drawingId" TEXT NOT NULL, "companyId" TEXT NOT NULL, @@ -56,29 +56,29 @@ CREATE TABLE "ballooningSelector" ( "updatedBy" TEXT, "updatedAt" TIMESTAMP WITH TIME ZONE, - CONSTRAINT "ballooningSelector_pkey" PRIMARY KEY ("id", "companyId"), - CONSTRAINT "ballooningSelector_id_unique" UNIQUE ("id"), - CONSTRAINT "ballooningSelector_pageNumber_check" CHECK ("pageNumber" > 0), - CONSTRAINT "ballooningSelector_xCoordinate_check" CHECK ("xCoordinate" >= 0 AND "xCoordinate" <= 1), - CONSTRAINT "ballooningSelector_yCoordinate_check" CHECK ("yCoordinate" >= 0 AND "yCoordinate" <= 1), - CONSTRAINT "ballooningSelector_width_check" CHECK ("width" > 0 AND "width" <= 1), - CONSTRAINT "ballooningSelector_height_check" CHECK ("height" > 0 AND "height" <= 1), - CONSTRAINT "ballooningSelector_xw_bounds_check" CHECK ("xCoordinate" + "width" <= 1), - CONSTRAINT "ballooningSelector_yh_bounds_check" CHECK ("yCoordinate" + "height" <= 1), - - CONSTRAINT "ballooningSelector_companyId_fkey" + CONSTRAINT "balloonAnchor_pkey" PRIMARY KEY ("id", "companyId"), + CONSTRAINT "balloonAnchor_id_unique" UNIQUE ("id"), + CONSTRAINT "balloonAnchor_pageNumber_check" CHECK ("pageNumber" > 0), + CONSTRAINT "balloonAnchor_xCoordinate_check" CHECK ("xCoordinate" >= 0 AND "xCoordinate" <= 1), + CONSTRAINT "balloonAnchor_yCoordinate_check" CHECK ("yCoordinate" >= 0 AND "yCoordinate" <= 1), + CONSTRAINT "balloonAnchor_width_check" CHECK ("width" > 0 AND "width" <= 1), + CONSTRAINT "balloonAnchor_height_check" CHECK ("height" > 0 AND "height" <= 1), + CONSTRAINT "balloonAnchor_xw_bounds_check" CHECK ("xCoordinate" + "width" <= 1), + CONSTRAINT "balloonAnchor_yh_bounds_check" CHECK ("yCoordinate" + "height" <= 1), + + CONSTRAINT "balloonAnchor_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "company"("id") ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT "ballooningSelector_createdBy_fkey" + CONSTRAINT "balloonAnchor_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "user"("id") ON UPDATE CASCADE, - CONSTRAINT "ballooningSelector_updatedBy_fkey" + CONSTRAINT "balloonAnchor_updatedBy_fkey" FOREIGN KEY ("updatedBy") REFERENCES "user"("id") ON UPDATE CASCADE, - CONSTRAINT "ballooningSelector_drawing_company_fkey" + CONSTRAINT "balloonAnchor_drawing_company_fkey" FOREIGN KEY ("drawingId", "companyId") - REFERENCES "ballooningDrawing"("id", "companyId") + REFERENCES "balloonDocument"("id", "companyId") ON DELETE CASCADE ON UPDATE CASCADE ); -CREATE TABLE "ballooningBalloon" ( +CREATE TABLE "balloon" ( "id" TEXT NOT NULL DEFAULT id('bbn'), "selectorId" TEXT NOT NULL, "drawingId" TEXT NOT NULL, @@ -96,31 +96,31 @@ CREATE TABLE "ballooningBalloon" ( "updatedBy" TEXT, "updatedAt" TIMESTAMP WITH TIME ZONE, - CONSTRAINT "ballooningBalloon_pkey" PRIMARY KEY ("id", "companyId"), - CONSTRAINT "ballooningBalloon_id_unique" UNIQUE ("id"), - CONSTRAINT "ballooningBalloon_selectorId_unique" UNIQUE ("selectorId"), - CONSTRAINT "ballooningBalloon_xCoordinate_check" CHECK ("xCoordinate" >= 0 AND "xCoordinate" <= 1), - CONSTRAINT "ballooningBalloon_yCoordinate_check" CHECK ("yCoordinate" >= 0 AND "yCoordinate" <= 1), - CONSTRAINT "ballooningBalloon_anchorX_check" CHECK ("anchorX" IS NULL OR ("anchorX" >= 0 AND "anchorX" <= 1)), - CONSTRAINT "ballooningBalloon_anchorY_check" CHECK ("anchorY" IS NULL OR ("anchorY" >= 0 AND "anchorY" <= 1)), + CONSTRAINT "balloon_pkey" PRIMARY KEY ("id", "companyId"), + CONSTRAINT "balloon_id_unique" UNIQUE ("id"), + CONSTRAINT "balloon_selectorId_unique" UNIQUE ("selectorId"), + CONSTRAINT "balloon_xCoordinate_check" CHECK ("xCoordinate" >= 0 AND "xCoordinate" <= 1), + CONSTRAINT "balloon_yCoordinate_check" CHECK ("yCoordinate" >= 0 AND "yCoordinate" <= 1), + CONSTRAINT "balloon_anchorX_check" CHECK ("anchorX" IS NULL OR ("anchorX" >= 0 AND "anchorX" <= 1)), + CONSTRAINT "balloon_anchorY_check" CHECK ("anchorY" IS NULL OR ("anchorY" >= 0 AND "anchorY" <= 1)), - CONSTRAINT "ballooningBalloon_companyId_fkey" + CONSTRAINT "balloon_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "company"("id") ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT "ballooningBalloon_createdBy_fkey" + CONSTRAINT "balloon_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "user"("id") ON UPDATE CASCADE, - CONSTRAINT "ballooningBalloon_updatedBy_fkey" + CONSTRAINT "balloon_updatedBy_fkey" FOREIGN KEY ("updatedBy") REFERENCES "user"("id") ON UPDATE CASCADE, - CONSTRAINT "ballooningBalloon_drawing_company_fkey" + CONSTRAINT "balloon_drawing_company_fkey" FOREIGN KEY ("drawingId", "companyId") - REFERENCES "ballooningDrawing"("id", "companyId") + REFERENCES "balloonDocument"("id", "companyId") ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT "ballooningBalloon_selector_company_fkey" + CONSTRAINT "balloon_selector_company_fkey" FOREIGN KEY ("selectorId", "companyId") - REFERENCES "ballooningSelector"("id", "companyId") + REFERENCES "balloonAnchor"("id", "companyId") ON DELETE CASCADE ON UPDATE CASCADE ); -CREATE TABLE "ballooningAnnotation" ( +CREATE TABLE "balloonAnnotation" ( "id" TEXT NOT NULL DEFAULT id('ban'), "drawingId" TEXT NOT NULL, "companyId" TEXT NOT NULL, @@ -138,44 +138,44 @@ CREATE TABLE "ballooningAnnotation" ( "updatedBy" TEXT, "updatedAt" TIMESTAMP WITH TIME ZONE, - CONSTRAINT "ballooningAnnotation_pkey" PRIMARY KEY ("id", "companyId"), - CONSTRAINT "ballooningAnnotation_id_unique" UNIQUE ("id"), - CONSTRAINT "ballooningAnnotation_pageNumber_check" CHECK ("pageNumber" > 0), - CONSTRAINT "ballooningAnnotation_xCoordinate_check" CHECK ("xCoordinate" >= 0 AND "xCoordinate" <= 1), - CONSTRAINT "ballooningAnnotation_yCoordinate_check" CHECK ("yCoordinate" >= 0 AND "yCoordinate" <= 1), - CONSTRAINT "ballooningAnnotation_companyId_fkey" + CONSTRAINT "balloonAnnotation_pkey" PRIMARY KEY ("id", "companyId"), + CONSTRAINT "balloonAnnotation_id_unique" UNIQUE ("id"), + CONSTRAINT "balloonAnnotation_pageNumber_check" CHECK ("pageNumber" > 0), + CONSTRAINT "balloonAnnotation_xCoordinate_check" CHECK ("xCoordinate" >= 0 AND "xCoordinate" <= 1), + CONSTRAINT "balloonAnnotation_yCoordinate_check" CHECK ("yCoordinate" >= 0 AND "yCoordinate" <= 1), + CONSTRAINT "balloonAnnotation_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "company"("id") ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT "ballooningAnnotation_createdBy_fkey" + CONSTRAINT "balloonAnnotation_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "user"("id") ON UPDATE CASCADE, - CONSTRAINT "ballooningAnnotation_updatedBy_fkey" + CONSTRAINT "balloonAnnotation_updatedBy_fkey" FOREIGN KEY ("updatedBy") REFERENCES "user"("id") ON UPDATE CASCADE, - CONSTRAINT "ballooningAnnotation_drawing_company_fkey" + CONSTRAINT "balloonAnnotation_drawing_company_fkey" FOREIGN KEY ("drawingId", "companyId") - REFERENCES "ballooningDrawing"("id", "companyId") + REFERENCES "balloonDocument"("id", "companyId") ON DELETE CASCADE ON UPDATE CASCADE ); -CREATE INDEX "ballooningDrawing_companyId_idx" ON "ballooningDrawing" ("companyId"); -CREATE INDEX "ballooningDrawing_qualityDocumentId_idx" ON "ballooningDrawing" ("qualityDocumentId"); +CREATE INDEX "balloonDocument_companyId_idx" ON "balloonDocument" ("companyId"); +CREATE INDEX "balloonDocument_qualityDocumentId_idx" ON "balloonDocument" ("qualityDocumentId"); -CREATE INDEX "ballooningSelector_companyId_idx" ON "ballooningSelector" ("companyId"); -CREATE INDEX "ballooningSelector_drawingId_idx" ON "ballooningSelector" ("drawingId"); -CREATE INDEX "ballooningSelector_drawing_page_idx" ON "ballooningSelector" ("drawingId", "companyId", "pageNumber"); -CREATE INDEX "ballooningSelector_active_page_idx" - ON "ballooningSelector" ("drawingId", "companyId", "pageNumber") +CREATE INDEX "balloonAnchor_companyId_idx" ON "balloonAnchor" ("companyId"); +CREATE INDEX "balloonAnchor_drawingId_idx" ON "balloonAnchor" ("drawingId"); +CREATE INDEX "balloonAnchor_drawing_page_idx" ON "balloonAnchor" ("drawingId", "companyId", "pageNumber"); +CREATE INDEX "balloonAnchor_active_page_idx" + ON "balloonAnchor" ("drawingId", "companyId", "pageNumber") WHERE "deletedAt" IS NULL; -CREATE INDEX "ballooningBalloon_companyId_idx" ON "ballooningBalloon" ("companyId"); -CREATE INDEX "ballooningBalloon_drawingId_idx" ON "ballooningBalloon" ("drawingId"); -CREATE INDEX "ballooningBalloon_selectorId_idx" ON "ballooningBalloon" ("selectorId"); -CREATE INDEX "ballooningBalloon_active_drawing_idx" - ON "ballooningBalloon" ("drawingId", "companyId") +CREATE INDEX "balloon_companyId_idx" ON "balloon" ("companyId"); +CREATE INDEX "balloon_drawingId_idx" ON "balloon" ("drawingId"); +CREATE INDEX "balloon_selectorId_idx" ON "balloon" ("selectorId"); +CREATE INDEX "balloon_active_drawing_idx" + ON "balloon" ("drawingId", "companyId") WHERE "deletedAt" IS NULL; -CREATE INDEX "ballooningAnnotation_companyId_idx" ON "ballooningAnnotation" ("companyId"); -CREATE INDEX "ballooningAnnotation_drawing_page_idx" ON "ballooningAnnotation" ("drawingId", "companyId", "pageNumber"); -CREATE INDEX "ballooningAnnotation_active_page_idx" - ON "ballooningAnnotation" ("drawingId", "companyId", "pageNumber") +CREATE INDEX "balloonAnnotation_companyId_idx" ON "balloonAnnotation" ("companyId"); +CREATE INDEX "balloonAnnotation_drawing_page_idx" ON "balloonAnnotation" ("drawingId", "companyId", "pageNumber"); +CREATE INDEX "balloonAnnotation_active_page_idx" + ON "balloonAnnotation" ("drawingId", "companyId", "pageNumber") WHERE "deletedAt" IS NULL; CREATE OR REPLACE FUNCTION enforce_unique_balloon_label_per_page() @@ -188,7 +188,7 @@ DECLARE BEGIN SELECT s."pageNumber" INTO v_page_number - FROM "ballooningSelector" s + FROM "balloonAnchor" s WHERE s."id" = NEW."selectorId" AND s."companyId" = NEW."companyId" AND s."deletedAt" IS NULL; @@ -199,8 +199,8 @@ BEGIN SELECT b."id" INTO v_conflict_id - FROM "ballooningBalloon" b - JOIN "ballooningSelector" s ON s."id" = b."selectorId" AND s."companyId" = b."companyId" + FROM "balloon" b + JOIN "balloonAnchor" s ON s."id" = b."selectorId" AND s."companyId" = b."companyId" WHERE b."drawingId" = NEW."drawingId" AND b."companyId" = NEW."companyId" AND b."label" = NEW."label" @@ -220,17 +220,17 @@ $$; CREATE TRIGGER "trg_balloon_unique_label_per_page" BEFORE INSERT OR UPDATE OF "selectorId", "drawingId", "label", "deletedAt" -ON "ballooningBalloon" +ON "balloon" FOR EACH ROW WHEN (NEW."deletedAt" IS NULL) EXECUTE FUNCTION enforce_unique_balloon_label_per_page(); -ALTER TABLE "ballooningDrawing" ENABLE ROW LEVEL SECURITY; -ALTER TABLE "ballooningSelector" ENABLE ROW LEVEL SECURITY; -ALTER TABLE "ballooningBalloon" ENABLE ROW LEVEL SECURITY; -ALTER TABLE "ballooningAnnotation" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "balloonDocument" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "balloonAnchor" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "balloon" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "balloonAnnotation" ENABLE ROW LEVEL SECURITY; -CREATE POLICY "SELECT" ON "public"."ballooningDrawing" +CREATE POLICY "SELECT" ON "public"."balloonDocument" FOR SELECT USING ( "companyId" = ANY ( ( @@ -240,7 +240,7 @@ FOR SELECT USING ( ) ); -CREATE POLICY "INSERT" ON "public"."ballooningDrawing" +CREATE POLICY "INSERT" ON "public"."balloonDocument" FOR INSERT WITH CHECK ( "companyId" = ANY ( ( @@ -250,7 +250,7 @@ FOR INSERT WITH CHECK ( ) ); -CREATE POLICY "UPDATE" ON "public"."ballooningDrawing" +CREATE POLICY "UPDATE" ON "public"."balloonDocument" FOR UPDATE USING ( "companyId" = ANY ( ( @@ -260,7 +260,7 @@ FOR UPDATE USING ( ) ); -CREATE POLICY "DELETE" ON "public"."ballooningDrawing" +CREATE POLICY "DELETE" ON "public"."balloonDocument" FOR DELETE USING ( "companyId" = ANY ( ( @@ -270,7 +270,7 @@ FOR DELETE USING ( ) ); -CREATE POLICY "SELECT" ON "public"."ballooningSelector" +CREATE POLICY "SELECT" ON "public"."balloonAnchor" FOR SELECT USING ( "companyId" = ANY ( ( @@ -280,7 +280,7 @@ FOR SELECT USING ( ) ); -CREATE POLICY "INSERT" ON "public"."ballooningSelector" +CREATE POLICY "INSERT" ON "public"."balloonAnchor" FOR INSERT WITH CHECK ( "companyId" = ANY ( ( @@ -290,7 +290,7 @@ FOR INSERT WITH CHECK ( ) ); -CREATE POLICY "UPDATE" ON "public"."ballooningSelector" +CREATE POLICY "UPDATE" ON "public"."balloonAnchor" FOR UPDATE USING ( "companyId" = ANY ( ( @@ -300,7 +300,7 @@ FOR UPDATE USING ( ) ); -CREATE POLICY "DELETE" ON "public"."ballooningSelector" +CREATE POLICY "DELETE" ON "public"."balloonAnchor" FOR DELETE USING ( "companyId" = ANY ( ( @@ -310,7 +310,7 @@ FOR DELETE USING ( ) ); -CREATE POLICY "SELECT" ON "public"."ballooningBalloon" +CREATE POLICY "SELECT" ON "public"."balloon" FOR SELECT USING ( "companyId" = ANY ( ( @@ -320,7 +320,7 @@ FOR SELECT USING ( ) ); -CREATE POLICY "INSERT" ON "public"."ballooningBalloon" +CREATE POLICY "INSERT" ON "public"."balloon" FOR INSERT WITH CHECK ( "companyId" = ANY ( ( @@ -330,7 +330,7 @@ FOR INSERT WITH CHECK ( ) ); -CREATE POLICY "UPDATE" ON "public"."ballooningBalloon" +CREATE POLICY "UPDATE" ON "public"."balloon" FOR UPDATE USING ( "companyId" = ANY ( ( @@ -340,7 +340,7 @@ FOR UPDATE USING ( ) ); -CREATE POLICY "DELETE" ON "public"."ballooningBalloon" +CREATE POLICY "DELETE" ON "public"."balloon" FOR DELETE USING ( "companyId" = ANY ( ( @@ -350,7 +350,7 @@ FOR DELETE USING ( ) ); -CREATE POLICY "SELECT" ON "public"."ballooningAnnotation" +CREATE POLICY "SELECT" ON "public"."balloonAnnotation" FOR SELECT USING ( "companyId" = ANY ( ( @@ -360,7 +360,7 @@ FOR SELECT USING ( ) ); -CREATE POLICY "INSERT" ON "public"."ballooningAnnotation" +CREATE POLICY "INSERT" ON "public"."balloonAnnotation" FOR INSERT WITH CHECK ( "companyId" = ANY ( ( @@ -370,7 +370,7 @@ FOR INSERT WITH CHECK ( ) ); -CREATE POLICY "UPDATE" ON "public"."ballooningAnnotation" +CREATE POLICY "UPDATE" ON "public"."balloonAnnotation" FOR UPDATE USING ( "companyId" = ANY ( ( @@ -380,7 +380,7 @@ FOR UPDATE USING ( ) ); -CREATE POLICY "DELETE" ON "public"."ballooningAnnotation" +CREATE POLICY "DELETE" ON "public"."balloonAnnotation" FOR DELETE USING ( "companyId" = ANY ( (