feat(berths): bulk price update + per-berth price API
Two new endpoints lift price editing out of the full berth-update form: - `PATCH /api/v1/berths/[id]/price` — single-berth price edit triggered inline from the berth list / detail (no need to open the heavy edit modal just to retag a price). - `POST /api/v1/berths/bulk-update-prices` — multi-row update from a selection in the berth list; transactional, audit-logged per row. Berth list column gets an inline price-edit affordance backed by the single-berth endpoint; the bulk action lives in the row-selection toolbar. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
34
src/app/api/v1/berths/[id]/price/route.ts
Normal file
34
src/app/api/v1/berths/[id]/price/route.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { updateBerthPriceSchema } from '@/lib/validators/berths';
|
||||||
|
import { updateBerthPrice } from '@/lib/services/berths.service';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/v1/berths/[id]/price
|
||||||
|
*
|
||||||
|
* Focused price-update endpoint gated by the dedicated
|
||||||
|
* `berths.update_prices` permission. Lets a role mutate berth pricing
|
||||||
|
* without granting the full `berths.edit` surface.
|
||||||
|
*
|
||||||
|
* Always audited (one `audit_log` row per call with
|
||||||
|
* `field_changed='price'` and the before/after values).
|
||||||
|
*/
|
||||||
|
export const PATCH = withAuth(
|
||||||
|
withPermission('berths', 'update_prices', async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, updateBerthPriceSchema);
|
||||||
|
const updated = await updateBerthPrice(params.id!, ctx.portId, body, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data: updated });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
35
src/app/api/v1/berths/bulk-update-prices/route.ts
Normal file
35
src/app/api/v1/berths/bulk-update-prices/route.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { bulkUpdateBerthPricesSchema } from '@/lib/validators/berths';
|
||||||
|
import { bulkUpdateBerthPrices } from '@/lib/services/berths.service';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/berths/bulk-update-prices
|
||||||
|
*
|
||||||
|
* Bulk update berth prices in a single transaction (up to 500 per call).
|
||||||
|
* Gated by `berths.update_prices`. Returns counts so the UI can present
|
||||||
|
* "Updated N · Unchanged M · Missing K" feedback.
|
||||||
|
*
|
||||||
|
* Audit: one `audit_log` row per actually-updated berth (idempotent —
|
||||||
|
* berths whose new price matches the existing value are skipped and
|
||||||
|
* counted as `unchanged`).
|
||||||
|
*/
|
||||||
|
export const POST = withAuth(
|
||||||
|
withPermission('berths', 'update_prices', async (req, ctx) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, bulkUpdateBerthPricesSchema);
|
||||||
|
const result = await bulkUpdateBerthPrices(ctx.portId, body, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data: result });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -68,6 +68,9 @@ export type BerthRow = {
|
|||||||
/** Most-advanced pipeline stage among the berth's active interests. Null
|
/** Most-advanced pipeline stage among the berth's active interests. Null
|
||||||
* when no active interest is linked. Read-only; computed server-side. */
|
* when no active interest is linked. Read-only; computed server-side. */
|
||||||
latestInterestStage?: string | null;
|
latestInterestStage?: string | null;
|
||||||
|
/** Count of non-terminal, non-archived interests linked to this berth.
|
||||||
|
* Drives the "Active interests" column + the demand sort. */
|
||||||
|
activeInterestCount?: number;
|
||||||
/** #67: source of the last status write. 'manual' when a human set it
|
/** #67: source of the last status write. 'manual' when a human set it
|
||||||
* via the API; 'automated' when a berth-rule fired; null on rows that
|
* via the API; 'automated' when a berth-rule fired; null on rows that
|
||||||
* haven't been touched since seed. The reconciliation surface treats
|
* haven't been touched since seed. The reconciliation surface treats
|
||||||
@@ -85,6 +88,7 @@ export const BERTH_COLUMN_OPTIONS: Array<{ id: string; label: string }> = [
|
|||||||
{ id: 'area', label: 'Area' },
|
{ id: 'area', label: 'Area' },
|
||||||
{ id: 'status', label: 'Status' },
|
{ id: 'status', label: 'Status' },
|
||||||
{ id: 'latestInterestStage', label: 'Latest deal stage' },
|
{ id: 'latestInterestStage', label: 'Latest deal stage' },
|
||||||
|
{ id: 'activeInterestCount', label: 'Active interests' },
|
||||||
{ id: 'sidePontoon', label: 'Side / Pontoon' },
|
{ id: 'sidePontoon', label: 'Side / Pontoon' },
|
||||||
{ id: 'dimensions', label: 'Dimensions' },
|
{ id: 'dimensions', label: 'Dimensions' },
|
||||||
{ id: 'nominalBoatSize', label: 'Nominal boat size' },
|
{ id: 'nominalBoatSize', label: 'Nominal boat size' },
|
||||||
@@ -281,6 +285,16 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'activeInterestCount',
|
||||||
|
accessorKey: 'activeInterestCount',
|
||||||
|
header: 'Active interests',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const n = row.original.activeInterestCount ?? 0;
|
||||||
|
if (n === 0) return <span className="text-muted-foreground">—</span>;
|
||||||
|
return <span className="font-medium tabular-nums">{n}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'sidePontoon',
|
id: 'sidePontoon',
|
||||||
header: 'Side / Pontoon',
|
header: 'Side / Pontoon',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { and, desc, eq, gte, lte, inArray, isNull, notInArray, sql } from 'drizzle-orm';
|
import { and, count, desc, eq, gte, lte, inArray, isNull, notInArray, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { berths, berthTags, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths';
|
import { berths, berthTags, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths';
|
||||||
@@ -19,6 +19,8 @@ import type {
|
|||||||
CreateBerthInput,
|
CreateBerthInput,
|
||||||
UpdateBerthInput,
|
UpdateBerthInput,
|
||||||
UpdateBerthStatusInput,
|
UpdateBerthStatusInput,
|
||||||
|
UpdateBerthPriceInput,
|
||||||
|
BulkUpdateBerthPricesInput,
|
||||||
ListBerthsQuery,
|
ListBerthsQuery,
|
||||||
AddMaintenanceLogInput,
|
AddMaintenanceLogInput,
|
||||||
UpdateWaitingListInput,
|
UpdateWaitingListInput,
|
||||||
@@ -89,6 +91,9 @@ export async function listBerths(portId: string, query: ListBerthsQuery) {
|
|||||||
return berths.status;
|
return berths.status;
|
||||||
case 'lengthM':
|
case 'lengthM':
|
||||||
return berths.lengthM;
|
return berths.lengthM;
|
||||||
|
case 'activeInterestCount':
|
||||||
|
// Sorted via correlated subquery in customOrderBy below.
|
||||||
|
return null;
|
||||||
default:
|
default:
|
||||||
// No sort requested → natural mooring order is the friendliest
|
// No sort requested → natural mooring order is the friendliest
|
||||||
// default for the berth grid (groups by pontoon letter).
|
// default for the berth grid (groups by pontoon letter).
|
||||||
@@ -96,6 +101,24 @@ export async function listBerths(portId: string, query: ListBerthsQuery) {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Sort by active interest count via correlated subquery. Cheap at
|
||||||
|
// marina scale (hundreds of berths × thousands of interests); revisit
|
||||||
|
// with a LATERAL join if multi-port reporting ever hits this hot.
|
||||||
|
const demandSort =
|
||||||
|
query.sort === 'activeInterestCount'
|
||||||
|
? [
|
||||||
|
sql`(
|
||||||
|
SELECT COUNT(*)::int
|
||||||
|
FROM ${interestBerths} ib
|
||||||
|
INNER JOIN ${interests} i ON i.id = ib.interest_id
|
||||||
|
WHERE ib.berth_id = ${berths.id}
|
||||||
|
AND i.port_id = ${portId}
|
||||||
|
AND i.archived_at IS NULL
|
||||||
|
AND i.outcome IS NULL
|
||||||
|
) ${sql.raw(query.order === 'asc' ? 'ASC' : 'DESC')}`,
|
||||||
|
]
|
||||||
|
: null;
|
||||||
|
|
||||||
const result = await buildListQuery({
|
const result = await buildListQuery({
|
||||||
table: berths,
|
table: berths,
|
||||||
portIdColumn: berths.portId,
|
portIdColumn: berths.portId,
|
||||||
@@ -104,7 +127,7 @@ export async function listBerths(portId: string, query: ListBerthsQuery) {
|
|||||||
updatedAtColumn: berths.updatedAt,
|
updatedAtColumn: berths.updatedAt,
|
||||||
filters,
|
filters,
|
||||||
sort: sortColumn ? { column: sortColumn, direction: query.order } : undefined,
|
sort: sortColumn ? { column: sortColumn, direction: query.order } : undefined,
|
||||||
customOrderBy: sortColumn ? undefined : NATURAL_MOORING_SORT,
|
customOrderBy: demandSort ?? (sortColumn ? undefined : NATURAL_MOORING_SORT),
|
||||||
page: query.page,
|
page: query.page,
|
||||||
pageSize: query.limit,
|
pageSize: query.limit,
|
||||||
searchColumns: [berths.mooringNumber, berths.area],
|
searchColumns: [berths.mooringNumber, berths.area],
|
||||||
@@ -139,16 +162,44 @@ export async function listBerths(portId: string, query: ListBerthsQuery) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const latestStageByBerthId = await getLatestInterestStageByBerth(berthIds, portId);
|
const latestStageByBerthId = await getLatestInterestStageByBerth(berthIds, portId);
|
||||||
|
const interestCountByBerthId = await getActiveInterestCountByBerth(berthIds, portId);
|
||||||
|
|
||||||
const data = (result.data as Array<Record<string, unknown>>).map((b) => ({
|
const data = (result.data as Array<Record<string, unknown>>).map((b) => ({
|
||||||
...b,
|
...b,
|
||||||
tags: tagsByBerthId[b.id as string] ?? [],
|
tags: tagsByBerthId[b.id as string] ?? [],
|
||||||
latestInterestStage: latestStageByBerthId[b.id as string] ?? null,
|
latestInterestStage: latestStageByBerthId[b.id as string] ?? null,
|
||||||
|
activeInterestCount: interestCountByBerthId[b.id as string] ?? 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { data, total: result.total };
|
return { data, total: result.total };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-berth active interest count. Mirrors the demand-sort subquery so
|
||||||
|
* the column value and the sort key stay consistent.
|
||||||
|
*/
|
||||||
|
async function getActiveInterestCountByBerth(
|
||||||
|
berthIds: string[],
|
||||||
|
portId: string,
|
||||||
|
): Promise<Record<string, number>> {
|
||||||
|
if (berthIds.length === 0) return {};
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
berthId: interestBerths.berthId,
|
||||||
|
count: count(interests.id),
|
||||||
|
})
|
||||||
|
.from(interestBerths)
|
||||||
|
.innerJoin(interests, eq(interestBerths.interestId, interests.id))
|
||||||
|
.where(and(activeInterestsWhere(portId), inArray(interestBerths.berthId, berthIds)))
|
||||||
|
.groupBy(interestBerths.berthId);
|
||||||
|
|
||||||
|
const map: Record<string, number> = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
map[row.berthId] = Number(row.count);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* For each berth id, returns the most-advanced pipeline stage among its
|
* For each berth id, returns the most-advanced pipeline stage among its
|
||||||
* linked active interests (outcome IS NULL, not archived). Used by the
|
* linked active interests (outcome IS NULL, not archived). Used by the
|
||||||
@@ -301,6 +352,167 @@ export async function updateBerth(
|
|||||||
return updated!;
|
return updated!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Update Price (single + bulk) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a single berth's price (+ optional currency). Gated upstream by
|
||||||
|
* the `berths.update_prices` permission so sales reps can retune prices
|
||||||
|
* without the full `berths.edit` surface. Writes a focused audit row
|
||||||
|
* with `field_changed='price'` and the before/after values so the audit
|
||||||
|
* log carries a clean price-change history.
|
||||||
|
*/
|
||||||
|
export async function updateBerthPrice(
|
||||||
|
id: string,
|
||||||
|
portId: string,
|
||||||
|
data: UpdateBerthPriceInput,
|
||||||
|
meta: AuditMeta,
|
||||||
|
) {
|
||||||
|
const existing = await db.query.berths.findFirst({
|
||||||
|
where: and(eq(berths.id, id), eq(berths.portId, portId)),
|
||||||
|
});
|
||||||
|
if (!existing) throw new NotFoundError('Berth');
|
||||||
|
|
||||||
|
const oldPrice = existing.price;
|
||||||
|
const oldCurrency = existing.priceCurrency;
|
||||||
|
const newPrice = data.price === null ? null : String(data.price);
|
||||||
|
const newCurrency = data.priceCurrency ?? oldCurrency;
|
||||||
|
|
||||||
|
if (oldPrice === newPrice && oldCurrency === newCurrency) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(berths)
|
||||||
|
.set({
|
||||||
|
price: newPrice,
|
||||||
|
priceCurrency: newCurrency,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(and(eq(berths.id, id), eq(berths.portId, portId)))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
void createAuditLog({
|
||||||
|
userId: meta.userId,
|
||||||
|
portId,
|
||||||
|
action: 'update',
|
||||||
|
entityType: 'berth',
|
||||||
|
entityId: id,
|
||||||
|
fieldChanged: 'price',
|
||||||
|
oldValue: toAuditJson({ price: oldPrice, priceCurrency: oldCurrency }),
|
||||||
|
newValue: toAuditJson({ price: newPrice, priceCurrency: newCurrency }),
|
||||||
|
ipAddress: meta.ipAddress,
|
||||||
|
userAgent: meta.userAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
emitToRoom(`port:${portId}`, 'berth:updated', {
|
||||||
|
berthId: id,
|
||||||
|
changedFields: ['price'],
|
||||||
|
});
|
||||||
|
|
||||||
|
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
|
||||||
|
dispatchWebhookEvent(portId, 'berth:updated', { berthId: id }),
|
||||||
|
);
|
||||||
|
|
||||||
|
return updated!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk berth price update. Loads all targeted rows in one query so we
|
||||||
|
* can validate port ownership in the same trip, then writes each change
|
||||||
|
* with a per-row audit entry. Berths that resolve to the same price are
|
||||||
|
* skipped (counted as `unchanged` in the response) so retries are idempotent.
|
||||||
|
*
|
||||||
|
* Returns counts so the UI can present "updated N, skipped M, missing K".
|
||||||
|
*/
|
||||||
|
export async function bulkUpdateBerthPrices(
|
||||||
|
portId: string,
|
||||||
|
data: BulkUpdateBerthPricesInput,
|
||||||
|
meta: AuditMeta,
|
||||||
|
) {
|
||||||
|
const targetIds = data.updates.map((u) => u.berthId);
|
||||||
|
const existingRows = await db
|
||||||
|
.select({
|
||||||
|
id: berths.id,
|
||||||
|
price: berths.price,
|
||||||
|
priceCurrency: berths.priceCurrency,
|
||||||
|
})
|
||||||
|
.from(berths)
|
||||||
|
.where(and(eq(berths.portId, portId), inArray(berths.id, targetIds)));
|
||||||
|
const byId = new Map(existingRows.map((b) => [b.id, b]));
|
||||||
|
|
||||||
|
const missing: string[] = [];
|
||||||
|
const updatedIds: string[] = [];
|
||||||
|
const unchangedIds: string[] = [];
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
for (const u of data.updates) {
|
||||||
|
const existing = byId.get(u.berthId);
|
||||||
|
if (!existing) {
|
||||||
|
missing.push(u.berthId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const oldPrice = existing.price;
|
||||||
|
const oldCurrency = existing.priceCurrency;
|
||||||
|
const newPrice = u.price === null ? null : String(u.price);
|
||||||
|
const newCurrency = u.priceCurrency ?? oldCurrency;
|
||||||
|
|
||||||
|
if (oldPrice === newPrice && oldCurrency === newCurrency) {
|
||||||
|
unchangedIds.push(u.berthId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(berths)
|
||||||
|
.set({
|
||||||
|
price: newPrice,
|
||||||
|
priceCurrency: newCurrency,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(and(eq(berths.id, u.berthId), eq(berths.portId, portId)));
|
||||||
|
|
||||||
|
void createAuditLog({
|
||||||
|
userId: meta.userId,
|
||||||
|
portId,
|
||||||
|
action: 'update',
|
||||||
|
entityType: 'berth',
|
||||||
|
entityId: u.berthId,
|
||||||
|
fieldChanged: 'price',
|
||||||
|
oldValue: toAuditJson({ price: oldPrice, priceCurrency: oldCurrency }),
|
||||||
|
newValue: toAuditJson({ price: newPrice, priceCurrency: newCurrency }),
|
||||||
|
metadata: { source: 'bulk_update_prices', batchSize: data.updates.length },
|
||||||
|
ipAddress: meta.ipAddress,
|
||||||
|
userAgent: meta.userAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
updatedIds.push(u.berthId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Realtime fan-out — one event per updated berth so any open list view
|
||||||
|
// refetches the affected rows.
|
||||||
|
for (const id of updatedIds) {
|
||||||
|
emitToRoom(`port:${portId}`, 'berth:updated', {
|
||||||
|
berthId: id,
|
||||||
|
changedFields: ['price'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedIds.length > 0) {
|
||||||
|
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) => {
|
||||||
|
for (const id of updatedIds) {
|
||||||
|
dispatchWebhookEvent(portId, 'berth:updated', { berthId: id });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
updated: updatedIds.length,
|
||||||
|
unchanged: unchangedIds.length,
|
||||||
|
missing: missing.length,
|
||||||
|
missingIds: missing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Update Status ────────────────────────────────────────────────────────────
|
// ─── Update Status ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function updateBerthStatus(
|
export async function updateBerthStatus(
|
||||||
|
|||||||
@@ -120,6 +120,39 @@ export const listBerthsSchema = baseListQuerySchema.extend({
|
|||||||
|
|
||||||
export type ListBerthsQuery = z.infer<typeof listBerthsSchema>;
|
export type ListBerthsQuery = z.infer<typeof listBerthsSchema>;
|
||||||
|
|
||||||
|
// ─── Update Berth Price ───────────────────────────────────────────────────────
|
||||||
|
// Single + bulk price update. Carved out from the general updateBerthSchema
|
||||||
|
// so that callers with the `berths.update_prices` permission (but NOT the
|
||||||
|
// generic `berths.edit`) can only mutate price + currency, not dimensions
|
||||||
|
// or mooring metadata.
|
||||||
|
|
||||||
|
export const updateBerthPriceSchema = z.object({
|
||||||
|
price: z.coerce.number().min(0, 'Price must be ≥ 0').nullable(),
|
||||||
|
priceCurrency: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.length(3, 'Currency must be a 3-letter ISO code')
|
||||||
|
.toUpperCase()
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UpdateBerthPriceInput = z.infer<typeof updateBerthPriceSchema>;
|
||||||
|
|
||||||
|
export const bulkUpdateBerthPricesSchema = z.object({
|
||||||
|
updates: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
berthId: z.string().uuid(),
|
||||||
|
price: z.coerce.number().min(0).nullable(),
|
||||||
|
priceCurrency: z.string().trim().length(3).toUpperCase().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1, 'At least one berth required')
|
||||||
|
.max(500, 'Bulk price updates capped at 500 berths per call'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type BulkUpdateBerthPricesInput = z.infer<typeof bulkUpdateBerthPricesSchema>;
|
||||||
|
|
||||||
// ─── Add Maintenance Log ──────────────────────────────────────────────────────
|
// ─── Add Maintenance Log ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const addMaintenanceLogSchema = z.object({
|
export const addMaintenanceLogSchema = z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user