diff --git a/apps/server/migrations/0020_deleted_record_insert.ts b/apps/server/migrations/0020_deleted_record_insert.ts index 23c725da1..e0858c204 100644 --- a/apps/server/migrations/0020_deleted_record_insert.ts +++ b/apps/server/migrations/0020_deleted_record_insert.ts @@ -26,7 +26,6 @@ const tables = [ "unit_revision", "user", "user_invite", - // "user_session", "workspace", "workspace_user", ]; diff --git a/apps/server/src/db/part-variation.ts b/apps/server/src/db/part-variation.ts index 871ec2f9d..3420fe3c0 100644 --- a/apps/server/src/db/part-variation.ts +++ b/apps/server/src/db/part-variation.ts @@ -1,24 +1,28 @@ import { DB, InsertPartVariation, + Part, PartVariation, PartVariationTreeNode, PartVariationTreeRoot, + PartVariationUpdate, } from "@cloud/shared"; +import { PartVariationType } from "@cloud/shared/src/schemas/public/PartVariationType"; import { ExpressionBuilder, Kysely } from "kysely"; +import { jsonObjectFrom } from "kysely/helpers/postgres"; +import _ from "lodash"; import { Result, err, ok, safeTry } from "neverthrow"; import { markUpdatedAt } from "../db/query"; -import { generateDatabaseId, tryQuery } from "../lib/db-utils"; -import { db } from "./kysely"; -import { getPart } from "./part"; +import { fromTransaction, generateDatabaseId, tryQuery } from "../lib/db-utils"; import { + BadRequestError, InternalServerError, NotFoundError, - BadRequestError, RouteError, } from "../lib/error"; -import { PartVariationType } from "@cloud/shared/src/schemas/public/PartVariationType"; -import { jsonObjectFrom } from "kysely/helpers/postgres"; +import { db } from "./kysely"; +import { getPart } from "./part"; +import { withUnitParent } from "./unit"; async function getOrCreateType( db: Kysely, @@ -87,27 +91,39 @@ async function getOrCreateMarket( return ok(insertResult); } +function validatePartNumber(part: Part, partNumber: string) { + const requiredPrefix = part.name + "-"; + if (!partNumber.startsWith(requiredPrefix)) { + return err( + new BadRequestError( + `Part number must start with "${requiredPrefix}" for part ${part.name}`, + ), + ); + } + return ok(partNumber); +} + export async function createPartVariation( db: Kysely, input: InsertPartVariation, ): Promise> { - const { components, ...newPartVariation } = input; + const { components, type: typeName, market: marketName, ...data } = input; const part = await getPart(db, input.partId); if (!part) { return err(new NotFoundError("Part not found")); } - const requiredPrefix = part.name + "-"; - if (!newPartVariation.partNumber.startsWith(requiredPrefix)) { - return err( - new BadRequestError( - `Part number must start with "${requiredPrefix}" for part ${part.name}`, - ), - ); + + const ids = components.map((c) => c.partVariationId); + if (ids.length !== _.uniq(ids).length) { + return err(new BadRequestError("Duplicate component ids")); } - const { type: typeName, market: marketName, ...data } = newPartVariation; return safeTry(async function* () { + const partNumber = yield* validatePartNumber( + part, + input.partNumber, + ).safeUnwrap(); let typeId: string | undefined = undefined; let marketId: string | undefined = undefined; @@ -131,6 +147,7 @@ export async function createPartVariation( .values({ id: generateDatabaseId("part_variation"), ...data, + partNumber, typeId, marketId, }) @@ -161,12 +178,17 @@ export async function createPartVariation( }); } -export async function getPartVariation(partVariationId: string) { +export async function getPartVariation( + workspaceId: string, + partVariationId: string, +) { return await db .selectFrom("part_variation") - .selectAll() + .selectAll("part_variation") .where("part_variation.id", "=", partVariationId) - + .where("part_variation.workspaceId", "=", workspaceId) + .select((eb) => [withPartVariationType(eb)]) + .select((eb) => [withPartVariationMarket(eb)]) .executeTakeFirst(); } @@ -178,6 +200,15 @@ export async function getPartVariationComponents(partVariationId: string) { .execute(); } +export async function getPartVariationUnits(partVariationId: string) { + return await db + .selectFrom("unit") + .selectAll("unit") + .where("unit.partVariationId", "=", partVariationId) + .select((eb) => withUnitParent(eb)) + .execute(); +} + type PartVariationEdge = { partNumber: string; partVariationId: string; @@ -186,10 +217,11 @@ type PartVariationEdge = { description: string | null; }; -export async function getPartVariationTree( +async function getPartVariationTreeEdges( + db: Kysely, partVariation: PartVariation, -): Promise { - const edges = await db +) { + return await db .withRecursive("part_variation_tree", (qb) => qb .selectFrom("part_variation_relation as mr") @@ -206,7 +238,7 @@ export async function getPartVariationTree( "part_variation.description", ]) .where("parentPartVariationId", "=", partVariation.id) - .unionAll((eb) => + .union((eb) => eb .selectFrom("part_variation_relation as mr") .innerJoin( @@ -231,7 +263,13 @@ export async function getPartVariationTree( .selectFrom("part_variation_tree") .selectAll() .execute(); +} +export async function getPartVariationTree( + db: Kysely, + partVariation: PartVariation, +): Promise { + const edges = await getPartVariationTreeEdges(db, partVariation); return buildPartVariationTree(partVariation, edges); } @@ -240,7 +278,10 @@ function buildPartVariationTree( edges: PartVariationEdge[], ) { const nodes = new Map(); - const root: PartVariationTreeRoot = { ...rootPartVariation, components: [] }; + const root: PartVariationTreeRoot = { + ...rootPartVariation, + components: [], + }; for (const edge of edges) { const parent = nodes.get(edge.parentPartVariationId) ?? root; @@ -266,6 +307,188 @@ function buildPartVariationTree( return root; } +async function getPartVariationImmediateChildren( + db: Kysely, + partVariationId: string, +) { + return await db + .selectFrom("part_variation_relation as mr") + .innerJoin("part_variation", "mr.childPartVariationId", "part_variation.id") + .select([ + "parentPartVariationId", + "count", + "childPartVariationId as partVariationId", + "part_variation.partNumber", + "part_variation.description", + ]) + .where("parentPartVariationId", "=", partVariationId) + .execute(); +} + +async function haveComponentsChanged( + db: Kysely, + partVariationId: string, + components: { partVariationId: string; count: number }[], +) { + const curComponents = await getPartVariationImmediateChildren( + db, + partVariationId, + ); + const makeObject = (cs: typeof components) => { + return Object.fromEntries(cs.map((c) => [c.partVariationId, c.count])); + }; + + const before = makeObject(curComponents); + const after = makeObject(components); + + return !_.isEqual(before, after); +} + +// Returns an error with the node of the cycle if one is detected, otherwise ok +function detectCycle(graph: PartVariationTreeRoot) { + const dfs = ( + node: PartVariationTreeNode, + pathVertices: Set, + ): Result => { + if (pathVertices.has(node.id)) { + return err({ id: node.id, partNumber: node.partNumber }); + } + if (node.components.length === 0) { + return ok(undefined); + } + + pathVertices.add(node.id); + for (const c of node.components) { + const res = dfs(c.partVariation, pathVertices); + if (res.isErr()) { + return res; + } + } + pathVertices.delete(node.id); + + return ok(undefined); + }; + + return dfs(graph, new Set()); +} + +export async function updatePartVariation( + db: Kysely, + partVariationId: string, + workspaceId: string, + update: PartVariationUpdate, +) { + const { components, type: typeName, market: marketName, ...data } = update; + + const partVariation = await getPartVariation(workspaceId, partVariationId); + if (partVariation === undefined) + return err(new NotFoundError("Part variation not found")); + + const part = await getPart(db, partVariation.partId); + if (part === undefined) return err(new NotFoundError("Part not found")); + + const ids = components.map((c) => c.partVariationId); + if (ids.length !== _.uniq(ids).length) { + return err(new BadRequestError("Duplicate component ids")); + } + + const existingUnits = await getPartVariationUnits(partVariationId); + const componentsChanged = await haveComponentsChanged( + db, + partVariationId, + components, + ); + + // Don't allow users to change part structure if there are existing units + if (componentsChanged && existingUnits.length > 0) { + return err( + new BadRequestError( + "Cannot change part variation components because there are existing units", + ), + ); + } + + return fromTransaction(async (tx) => { + return safeTry(async function* () { + let typeId: string | null = null; + let marketId: string | null = null; + + if (typeName) { + const type = yield* ( + await getOrCreateType(tx, typeName, workspaceId) + ).safeUnwrap(); + typeId = type.id; + } + if (marketName) { + const market = yield* ( + await getOrCreateMarket(tx, marketName, workspaceId) + ).safeUnwrap(); + marketId = market.id; + } + + yield* tryQuery( + tx + .updateTable("part_variation") + .set({ + ...data, + typeId, + marketId, + }) + .where("id", "=", partVariationId) + .execute(), + ).safeUnwrap(); + + const updatedPartVariation = await getPartVariation( + workspaceId, + partVariationId, + ); + if (updatedPartVariation === undefined) { + return err(new InternalServerError("Failed to update part variation")); + } + + if (!componentsChanged) { + return ok(undefined); + } + + // Rebuild this level of the component tree + yield* tryQuery( + tx + .deleteFrom("part_variation_relation") + .where("parentPartVariationId", "=", partVariationId) + .execute(), + ).safeUnwrap(); + + if (components.length > 0) { + yield* tryQuery( + tx + .insertInto("part_variation_relation") + .values( + components.map((c) => ({ + parentPartVariationId: updatedPartVariation.id, + childPartVariationId: c.partVariationId, + workspaceId, + count: c.count, + })), + ) + .execute(), + ).safeUnwrap(); + + const graph = await getPartVariationTree(tx, updatedPartVariation); + yield* detectCycle(graph) + .mapErr( + (e) => + new BadRequestError( + `Cyclic reference detected in component hierarchy at: ${e.partNumber}, not allowed`, + ), + ) + .safeUnwrap(); + } + + return ok(undefined); + }); + }); +} + export function withPartVariationType( eb: ExpressionBuilder, ) { diff --git a/apps/server/src/db/unit.ts b/apps/server/src/db/unit.ts index c5317bac5..e818636f4 100644 --- a/apps/server/src/db/unit.ts +++ b/apps/server/src/db/unit.ts @@ -1,4 +1,4 @@ -import { generateDatabaseId } from "../lib/db-utils"; +import { fromTransaction, generateDatabaseId, tryQuery } from "../lib/db-utils"; import { BadRequestError, DuplicateError, @@ -20,16 +20,17 @@ import { ExpressionBuilder, Kysely } from "kysely"; import { jsonObjectFrom } from "kysely/helpers/postgres"; import _ from "lodash"; import { User } from "lucia"; -import { fromPromise } from "neverthrow"; +import { Result, err, fromPromise, ok, safeTry } from "neverthrow"; import { db } from "./kysely"; import { getPartVariation, getPartVariationComponents } from "./part-variation"; import { markUpdatedAt } from "./query"; -export async function getUnit(id: string) { +export async function getUnit(unitId: string, workspaceId: string) { return await db .selectFrom("unit") .selectAll() - .where("unit.id", "=", id) + .where("unit.id", "=", unitId) + .where("workspaceId", "=", workspaceId) .executeTakeFirst(); } @@ -43,7 +44,10 @@ export async function createUnit( db.transaction().execute(async (tx) => { const { components, projectId, ...newUnit } = unit; - const partVariation = await getPartVariation(unit.partVariationId); + const partVariation = await getPartVariation( + workspaceId, + unit.partVariationId, + ); if (!partVariation) { throw new NotFoundError("PartVariation not found"); } @@ -165,78 +169,89 @@ export async function doUnitComponentSwap( unit: Unit, user: WorkspaceUser, input: SwapUnitComponent, -) { +): Promise> { const unitComponents = await getUnitComponentsWithPartVariation(unit.id); - return fromPromise( - db.transaction().execute(async (tx) => { - const oldUnitComponent = await tx - .selectFrom("unit") - .selectAll() - .where("unit.id", "=", input.oldUnitComponentId) - .where("unit.workspaceId", "=", user.workspaceId) - .executeTakeFirstOrThrow( - () => new NotFoundError("Old component not found"), - ); + return fromTransaction(async (tx) => { + return safeTry(async function* () { + const oldUnitComponent = await getUnit( + input.oldUnitComponentId, + user.workspaceId, + ); + if (oldUnitComponent === undefined) + return err(new NotFoundError("Old component not found")); - const newUnitComponent = await tx - .selectFrom("unit") - .selectAll() - .where("unit.id", "=", input.newUnitComponentId) - .where("unit.workspaceId", "=", user.workspaceId) - .executeTakeFirstOrThrow( - () => new NotFoundError("New component not found"), - ); + const newUnitComponent = await getUnit( + input.newUnitComponentId, + user.workspaceId, + ); + if (newUnitComponent === undefined) + return err(new NotFoundError("New component not found")); if ( oldUnitComponent.partVariationId !== newUnitComponent.partVariationId ) { - throw new BadRequestError("PartVariation mismatch"); + return err(new BadRequestError("PartVariation mismatch")); } if ( !unitComponents.some((hc) => hc.unitId === input.oldUnitComponentId) ) { - throw new BadRequestError("Old component is not a part of the unit"); + return err( + new BadRequestError("Old component is not a part of the unit"), + ); } - await tx - .deleteFrom("unit_relation as hr") - .where("hr.parentUnitId", "=", unit.id) - .where("hr.childUnitId", "=", input.oldUnitComponentId) - .execute(); + yield* ( + await tryQuery( + tx + .deleteFrom("unit_relation as hr") + .where("hr.parentUnitId", "=", unit.id) + .where("hr.childUnitId", "=", input.oldUnitComponentId) + .execute(), + ) + ).safeUnwrap(); + + yield* ( + await tryQuery( + tx + .insertInto("unit_relation") + .values({ + parentUnitId: unit.id, + childUnitId: input.newUnitComponentId, + workspaceId: unit.workspaceId, + }) + .execute(), + ) + ).safeUnwrap(); + + yield* ( + await tryQuery( + tx + .insertInto("unit_revision") + .values([ + { + revisionType: "remove", + userId: user.userId, + unitId: unit.id, + componentId: input.oldUnitComponentId, + reason: input.reason ?? "Component swap", + }, + { + revisionType: "add", + userId: user.userId, + unitId: unit.id, + componentId: input.newUnitComponentId, + reason: input.reason ?? "Component swap", + }, + ]) + .execute(), + ) + ).safeUnwrap(); - await tx - .insertInto("unit_relation") - .values({ - parentUnitId: unit.id, - childUnitId: input.newUnitComponentId, - workspaceId: unit.workspaceId, - }) - .execute(); - - await tx - .insertInto("unit_revision") - .values([ - { - revisionType: "remove", - userId: user.userId, - unitId: unit.id, - componentId: input.oldUnitComponentId, - reason: input.reason ?? "Component swap", - }, - { - revisionType: "add", - userId: user.userId, - unitId: unit.id, - componentId: input.newUnitComponentId, - reason: input.reason ?? "Component swap", - }, - ]) - .execute(); - }), - (e) => e as RouteError, - ); + return ok(undefined); + }); + }); } export const notInUse = ({ diff --git a/apps/server/src/routes/part-variation.ts b/apps/server/src/routes/part-variation.ts index 97d0b337d..ae02b64b1 100644 --- a/apps/server/src/routes/part-variation.ts +++ b/apps/server/src/routes/part-variation.ts @@ -1,13 +1,15 @@ -import { insertPartVariation } from "@cloud/shared"; +import { insertPartVariation, partVariationUpdate } from "@cloud/shared"; import Elysia, { t } from "elysia"; import { db } from "../db/kysely"; import { createPartVariation, + getPartVariation, getPartVariationTree, + getPartVariationUnits, + updatePartVariation, withPartVariationMarket, withPartVariationType, } from "../db/part-variation"; -import { withUnitParent } from "../db/unit"; import { checkWorkspacePerm } from "../lib/perm/workspace"; import { WorkspaceMiddleware } from "../middlewares/workspace"; import { fromTransaction } from "../lib/db-utils"; @@ -73,20 +75,16 @@ export const PartVariationRoute = new Elysia({ .get( "/", async ({ workspace, params: { partVariationId }, error }) => { - const partVariation = await db - .selectFrom("part_variation") - .selectAll("part_variation") - .where("part_variation.id", "=", partVariationId) - .where("part_variation.workspaceId", "=", workspace.id) - .select((eb) => [withPartVariationType(eb)]) - .select((eb) => [withPartVariationMarket(eb)]) - .executeTakeFirst(); + const partVariation = await getPartVariation( + workspace.id, + partVariationId, + ); if (partVariation === undefined) { return error(404, "PartVariation not found"); } - return await getPartVariationTree(partVariation); + return await getPartVariationTree(db, partVariation); }, { params: t.Object({ partVariationId: t.String() }), @@ -100,18 +98,44 @@ export const PartVariationRoute = new Elysia({ }, }, ) + .patch( + "/", + async ({ workspace, body, params: { partVariationId }, error }) => { + const partVariation = await getPartVariation( + workspace.id, + partVariationId, + ); + if (partVariation === undefined) + return error(404, "Part variation not found"); + + const res = await updatePartVariation( + db, + partVariationId, + workspace.id, + body, + ); + + if (res.isErr()) return error(res.error.code, res.error); + + return {}; + }, + { + params: t.Object({ partVariationId: t.String() }), + body: partVariationUpdate, + + async beforeHandle({ workspaceUser, error }) { + const perm = await checkWorkspacePerm({ workspaceUser }); + return perm.match( + (perm) => (perm.canRead() ? undefined : error("Forbidden")), + (err) => error(403, err), + ); + }, + }, + ) .get( "/unit", - async ({ workspace, params: { partVariationId } }) => { - const partVariations = await db - .selectFrom("unit") - .selectAll("unit") - .where("unit.workspaceId", "=", workspace.id) - .where("unit.partVariationId", "=", partVariationId) - .select((eb) => withUnitParent(eb)) - .execute(); - - return partVariations; + async ({ params: { partVariationId } }) => { + return await getPartVariationUnits(partVariationId); }, { params: t.Object({ partVariationId: t.String() }), diff --git a/apps/server/src/routes/unit.ts b/apps/server/src/routes/unit.ts index 81a37d7c5..fc412143d 100644 --- a/apps/server/src/routes/unit.ts +++ b/apps/server/src/routes/unit.ts @@ -108,7 +108,7 @@ export const UnitRoute = new Elysia({ prefix: "/unit", name: "UnitRoute" }) .patch( "/", async ({ workspaceUser, body, error, params: { unitId } }) => { - const unit = await getUnit(unitId); + const unit = await getUnit(unitId, workspaceUser.workspaceId); if (!unit) return error("Not Found"); const res = await doUnitComponentSwap(unit, workspaceUser, body); @@ -134,8 +134,8 @@ export const UnitRoute = new Elysia({ prefix: "/unit", name: "UnitRoute" }) ) .get( "/revisions", - async ({ params: { unitId } }) => { - const unit = await getUnit(unitId); + async ({ workspaceUser, params: { unitId } }) => { + const unit = await getUnit(unitId, workspaceUser.workspaceId); if (!unit) return error("Not Found"); return await getUnitRevisions(unit.id); }, diff --git a/apps/web/src/components/project/new-project.tsx b/apps/web/src/components/project/new-project.tsx index 6ed0fe49e..2b421ae0a 100644 --- a/apps/web/src/components/project/new-project.tsx +++ b/apps/web/src/components/project/new-project.tsx @@ -1,4 +1,4 @@ -import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, @@ -6,7 +6,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { useState } from "react"; -import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; import { Form, @@ -32,22 +32,16 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; -import { - Select, - SelectTrigger, - SelectValue, - SelectContent, - SelectItem, -} from "@/components/ui/select"; import { handleError } from "@/lib/utils"; -import { Workspace, CreateProjectSchema, PartVariation } from "@cloud/shared"; +import { useWorkspaceUser } from "@/hooks/use-workspace-user"; +import { client } from "@/lib/client"; +import { CreateProjectSchema, PartVariation, Workspace } from "@cloud/shared"; import { typeboxResolver } from "@hookform/resolvers/typebox"; -import { Link, useRouter } from "@tanstack/react-router"; import { useMutation } from "@tanstack/react-query"; -import { client } from "@/lib/client"; -import { useWorkspaceUser } from "@/hooks/use-workspace-user"; +import { Link, useRouter } from "@tanstack/react-router"; import { Info } from "lucide-react"; +import { Combobox } from "@/components/ui/combobox"; type Props = { workspace: Workspace; @@ -172,28 +166,19 @@ export default function NewProjectButton({ workspace, partVariations }: Props) { Part Variation {partVariations.length > 0 ? ( - +
+ + form.setValue("partVariationId", val ?? "") + } + displaySelector={(p) => p.partNumber} + valueSelector={(p) => p.id} + descriptionSelector={(p) => p.description ?? ""} + searchText="Search part variation..." + /> +
) : (
No part variations found, go{" "} diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx index 40fe2e2f3..da9cf58ae 100644 --- a/apps/web/src/components/ui/combobox.tsx +++ b/apps/web/src/components/ui/combobox.tsx @@ -16,6 +16,7 @@ import { } from "@/components/ui/popover"; import { useState } from "react"; import { CommandList } from "cmdk"; +import { ScrollArea } from "./scroll-area"; type Props = { options: T[]; @@ -26,6 +27,9 @@ type Props = { descriptionSelector?: (val: T) => string; placeholder?: string; searchText?: string; + avoidCollisions?: boolean; + side?: "top" | "bottom"; + disabled?: boolean; }; export function Combobox({ @@ -37,15 +41,62 @@ export function Combobox({ descriptionSelector, searchText, placeholder, + avoidCollisions, + side, + disabled = false, }: Props) { const [open, setOpen] = useState(false); const curValue = options.find((opt) => valueSelector(opt) === value); const label = curValue ? displaySelector(curValue) : placeholder; + const items = ( + + Nothing found. + + [data-radix-scroll-area-viewport]]:max-h-56"} + > + {options.map((opt) => ( + { + setValue(val); + setOpen(false); + }} + className="cursor-pointer" + keywords={[displaySelector(opt)]} + > + + {descriptionSelector ? ( +
+
+ {displaySelector(opt)} +
+
+ {descriptionSelector(opt)} +
+
+ ) : ( + displaySelector(opt) + )} +
+ ))} +
+
+
+ ); + const input = ; + return ( - - + + +
+ ))} + + + When you try to initialize an unit based on this part + variation, then you first need to make sure all its + components exist. + + + )} + + )} + + + + + + + + + + + ); +}; + +export default EditPartVariation; diff --git a/apps/web/src/components/unit/swap-unit.tsx b/apps/web/src/components/unit/swap-unit.tsx index e724a6a12..42b1d56f2 100644 --- a/apps/web/src/components/unit/swap-unit.tsx +++ b/apps/web/src/components/unit/swap-unit.tsx @@ -1,6 +1,6 @@ -import { toast } from "sonner"; -import { useState } from "react"; import { Button } from "@/components/ui/button"; +import { useState } from "react"; +import { toast } from "sonner"; import { Form, @@ -24,25 +24,20 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Edit } from "lucide-react"; import { Textarea } from "@/components/ui/textarea"; -import { typeboxResolver } from "@hookform/resolvers/typebox"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { client } from "@/lib/client"; import { getUnitQueryKey, getUnitsQueryOpts } from "@/lib/queries/unit"; +import { handleError } from "@/lib/utils"; import { - Workspace, - UnitTreeRoot, SwapUnitComponent, + UnitTreeRoot, + Workspace, swapUnitComponent, } from "@cloud/shared"; +import { typeboxResolver } from "@hookform/resolvers/typebox"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Edit } from "lucide-react"; +import { Combobox } from "@/components/ui/combobox"; type FormSchema = SwapUnitComponent; @@ -87,7 +82,7 @@ const SwapUnit = ({ workspace, unit }: Props) => { toast.promise(swapUnit.mutateAsync(values), { loading: "Creating unit revision...", success: "Revision created.", - error: (err) => `${err}`, + error: handleError, }); } @@ -133,18 +128,18 @@ const SwapUnit = ({ workspace, unit }: Props) => { Old Component - +
+ + form.setValue("oldUnitComponentId", val ?? "") + } + displaySelector={(val) => val.serialNumber} + valueSelector={(val) => val.id} + searchText="Search unit..." + /> +
Which component do you want to take out? @@ -161,22 +156,19 @@ const SwapUnit = ({ workspace, unit }: Props) => { New Component - +
+ + form.setValue("newUnitComponentId", val ?? "") + } + displaySelector={(val) => val.serialNumber} + valueSelector={(val) => val.id} + searchText="Search unit..." + disabled={selectedPartVariation === undefined} + /> +
Which component do you want to put in its place? diff --git a/apps/web/src/routes/_protected/workspace/$namespace/part/$partId/index.tsx b/apps/web/src/routes/_protected/workspace/$namespace/part/$partId/index.tsx index 385bfbcf9..104b71150 100644 --- a/apps/web/src/routes/_protected/workspace/$namespace/part/$partId/index.tsx +++ b/apps/web/src/routes/_protected/workspace/$namespace/part/$partId/index.tsx @@ -28,6 +28,7 @@ import { Separator } from "@/components/ui/separator"; import CreatePartVariation, { CreatePartVariationDefaultValues, } from "@/components/unit/create-part-variation"; +import EditPartVariation from "@/components/unit/edit-part-variation"; import { PartVariationTreeVisualization } from "@/components/visualization/tree-visualization"; import { useWorkspaceUser } from "@/hooks/use-workspace-user"; import { client } from "@/lib/client"; @@ -40,9 +41,14 @@ import { getPartVariationTypesQueryOpts, getPartVariationsQueryOpts, } from "@/lib/queries/part-variation"; +import { getPartVariationUnitQueryOpts } from "@/lib/queries/unit"; import { removePrefix } from "@/lib/string"; import { Route as WorkspaceIndexRoute } from "@/routes/_protected/workspace/$namespace"; -import { PartVariation, PartVariationTreeNode } from "@cloud/shared"; +import { + PartVariation, + PartVariationTreeNode, + PartVariationTreeRoot, +} from "@cloud/shared"; import { PartVariationMarket } from "@cloud/shared/src/schemas/public/PartVariationMarket"; import { PartVariationType } from "@cloud/shared/src/schemas/public/PartVariationType"; import { @@ -52,7 +58,7 @@ import { } from "@tanstack/react-query"; import { Link, createFileRoute, useRouter } from "@tanstack/react-router"; import { ColumnDef } from "@tanstack/react-table"; -import { ArrowRight, MoreHorizontal, Plus } from "lucide-react"; +import { ArrowRight, MoreHorizontal, Pencil, Plus } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; @@ -79,15 +85,17 @@ export const Route = createFileRoute( }, }); -const partVariationColumns: ( - openCreateDialog: (variant: PartVariation) => Promise, -) => ColumnDef< +const partVariationColumns: (props: { + openCreateDialog: (variant: PartVariation) => Promise; + openEditDialog: (variant: PartVariation) => Promise; + canWrite: boolean; +}) => ColumnDef< PartVariation & { unitCount: number; market?: PartVariationMarket | null; type?: PartVariationType | null; } ->[] = (openCreateDialog: (variant: PartVariation) => Promise) => [ +>[] = ({ openCreateDialog, openEditDialog, canWrite }) => [ { accessorKey: "name", header: "Part Number", @@ -154,10 +162,22 @@ const partVariationColumns: ( Copy ID + { + openEditDialog(partVariant); + }} + disabled={!canWrite} + > +
+ +
Edit
+
+
{ openCreateDialog(partVariant); }} + disabled={!canWrite} >
@@ -213,6 +233,7 @@ function PartPage() { const { partId } = Route.useParams(); const [createOpen, setCreateOpen] = useState(false); + const [editOpen, setEditOpen] = useState(false); const router = useRouter(); const queryClient = useQueryClient(); @@ -257,10 +278,18 @@ function PartPage() { enabled: selectedPartVariationId !== undefined, }); - const [defaultValues, setDefaultValues] = useState< + const [createDefaultValues, setCreateDefaultValues] = useState< CreatePartVariationDefaultValues | undefined >(); + const [editingPartVariation, setEditingPartVariation] = useState< + PartVariationTreeRoot | undefined + >(); + const [ + editingPartVariationHasExistingUnits, + setEditingPartVariationHasExistingUnits, + ] = useState(false); + const openCreateDialog = useCallback( async (variant?: PartVariation) => { if (variant) { @@ -270,8 +299,10 @@ function PartPage() { context: { workspace }, }), ); - setDefaultValues({ + setCreateDefaultValues({ partNumber: removePrefix(tree.partNumber, part.name + "-"), + type: tree.type?.name, + market: tree.market?.name, hasComponents: tree.components.length > 0, components: tree.components.map((c) => ({ count: c.count, @@ -280,7 +311,7 @@ function PartPage() { description: tree.description ?? undefined, }); } else { - setDefaultValues(undefined); + setCreateDefaultValues(undefined); } setCreateOpen(true); @@ -288,9 +319,37 @@ function PartPage() { [queryClient, workspace, part.name], ); + const openEditDialog = useCallback( + async (variant: PartVariation) => { + const tree = await queryClient.ensureQueryData( + getPartVariationQueryOpts({ + partVariationId: variant.id, + context: { workspace }, + }), + ); + const existingUnits = await queryClient.ensureQueryData( + getPartVariationUnitQueryOpts({ + partVariationId: variant.id, + context: { workspace }, + }), + ); + setEditingPartVariation(tree); + setEditingPartVariationHasExistingUnits(existingUnits.length > 0); + setEditOpen(true); + }, + [queryClient, workspace], + ); + + const canWrite = workspaceUserPerm.canWrite(); + const columns = useMemo( - () => partVariationColumns(openCreateDialog), - [openCreateDialog], + () => + partVariationColumns({ + openCreateDialog, + openEditDialog, + canWrite, + }), + [openCreateDialog, openEditDialog, canWrite], ); return ( @@ -345,8 +404,23 @@ function PartPage() { open={createOpen} setOpen={setCreateOpen} openDialog={openCreateDialog} - defaultValues={defaultValues} - setDefaultValues={setDefaultValues} + defaultValues={createDefaultValues} + setDefaultValues={setCreateDefaultValues} + partVariationTypes={partVariationTypes} + partVariationMarkets={partVariationMarkets} + /> + + )} + {workspaceUserPerm.canWrite() && editingPartVariation && ( + <> +
+ diff --git a/packages/shared/src/types/part-variation.ts b/packages/shared/src/types/part-variation.ts index 9f2d9ee1e..f42ecd193 100644 --- a/packages/shared/src/types/part-variation.ts +++ b/packages/shared/src/types/part-variation.ts @@ -1,7 +1,13 @@ import { t, Static } from "elysia"; -import { PartVariation } from "../schemas/public/PartVariation"; -export type { PartVariation }; +import { PartVariation as SchemaPartVariation } from "../schemas/public/PartVariation"; +import { PartVariationMarket } from "../schemas/public/PartVariationMarket"; +import { PartVariationType } from "../schemas/public/PartVariationType"; + +export type PartVariation = SchemaPartVariation & { + type?: PartVariationType | null; + market?: PartVariationMarket | null; +}; export const partVariationComponent = t.Object({ partVariationId: t.String(), @@ -23,6 +29,17 @@ export const insertPartVariation = t.Object({ export type InsertPartVariation = Static; +export const partVariationUpdate = t.Object({ + type: t.Optional(t.String()), + market: t.Optional(t.String()), + description: t.Optional(t.String()), + components: t.Array(t.Omit(partVariationComponent, ["partNumber"]), { + default: [], + }), +}); + +export type PartVariationUpdate = Static; + export type PartVariationTreeRoot = PartVariation & { components: { count: number; partVariation: PartVariationTreeNode }[]; };