mirror of
https://github.com/System-End/scraps.git
synced 2026-04-19 16:28:20 +00:00
update logic fix
This commit is contained in:
parent
3e897fbd26
commit
df2039908f
7 changed files with 272 additions and 54 deletions
|
|
@ -1,5 +1,3 @@
|
||||||
ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL_1FAEFB6177B4672DEE07F9D3AFC62588CCD2631EDCF22E8CCC1FB35B501C9C86on
|
|
||||||
|
|
||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
|
||||||
66
backend/dist/index.js
vendored
66
backend/dist/index.js
vendored
|
|
@ -34517,6 +34517,26 @@ admin.get("/users/:id/bonuses", async ({ params, headers }) => {
|
||||||
return { error: "Failed to fetch user bonuses" };
|
return { error: "Failed to fetch user bonuses" };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
admin.delete("/bonuses/:id", async ({ params, headers, status: status2 }) => {
|
||||||
|
try {
|
||||||
|
const user2 = await requireAdmin(headers);
|
||||||
|
if (!user2)
|
||||||
|
return status2(401, { error: "Unauthorized" });
|
||||||
|
const bonusId = parseInt(params.id);
|
||||||
|
if (!Number.isInteger(bonusId) || bonusId <= 0) {
|
||||||
|
return status2(400, { error: "Invalid bonus id" });
|
||||||
|
}
|
||||||
|
const bonus = await db.select({ id: userBonusesTable.id }).from(userBonusesTable).where(eq(userBonusesTable.id, bonusId)).limit(1);
|
||||||
|
if (!bonus[0]) {
|
||||||
|
return status2(404, { error: "Bonus not found" });
|
||||||
|
}
|
||||||
|
await db.delete(userBonusesTable).where(eq(userBonusesTable.id, bonusId));
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
return status2(500, { error: "Failed to delete bonus" });
|
||||||
|
}
|
||||||
|
});
|
||||||
admin.get("/reviews", async ({ headers, query }) => {
|
admin.get("/reviews", async ({ headers, query }) => {
|
||||||
try {
|
try {
|
||||||
const user2 = await requireReviewer(headers);
|
const user2 = await requireReviewer(headers);
|
||||||
|
|
@ -34727,7 +34747,6 @@ admin.post("/reviews/:id", async ({ params, body, headers }) => {
|
||||||
}
|
}
|
||||||
let scrapsAwarded = 0;
|
let scrapsAwarded = 0;
|
||||||
if (action === "approved") {
|
if (action === "approved") {
|
||||||
const hours = hoursOverride ?? project[0].hours ?? 0;
|
|
||||||
const tier = tierOverride ?? project[0].tier ?? 1;
|
const tier = tierOverride ?? project[0].tier ?? 1;
|
||||||
const { effectiveHours } = await computeEffectiveHoursForProject({
|
const { effectiveHours } = await computeEffectiveHoursForProject({
|
||||||
...project[0],
|
...project[0],
|
||||||
|
|
@ -35243,10 +35262,9 @@ admin.post("/shop/items", async ({ headers, body, status: status2 }) => {
|
||||||
category,
|
category,
|
||||||
count,
|
count,
|
||||||
baseProbability,
|
baseProbability,
|
||||||
baseUpgradeCost,
|
rollCostOverride,
|
||||||
costMultiplier,
|
perRollMultiplier: bodyPerRollMultiplier,
|
||||||
boostAmount,
|
upgradeBudgetMultiplier: bodyUpgradeBudgetMultiplier
|
||||||
rollCostOverride
|
|
||||||
} = body;
|
} = body;
|
||||||
if (!name?.trim() || !image?.trim() || !description?.trim() || !category?.trim()) {
|
if (!name?.trim() || !image?.trim() || !description?.trim() || !category?.trim()) {
|
||||||
return status2(400, { error: "All fields are required" });
|
return status2(400, { error: "All fields are required" });
|
||||||
|
|
@ -35262,8 +35280,8 @@ admin.post("/shop/items", async ({ headers, body, status: status2 }) => {
|
||||||
try {
|
try {
|
||||||
const dollarCost = typeof price === "number" ? price / SCRAPS_PER_DOLLAR : 0;
|
const dollarCost = typeof price === "number" ? price / SCRAPS_PER_DOLLAR : 0;
|
||||||
const pricing = computeItemPricing(dollarCost, baseProbability, count ?? 1);
|
const pricing = computeItemPricing(dollarCost, baseProbability, count ?? 1);
|
||||||
const perRollMultiplierVal = body?.perRollMultiplier ?? 0.05;
|
const perRollMultiplierVal = bodyPerRollMultiplier ?? 0.05;
|
||||||
const upgradeBudgetMultiplierVal = body?.upgradeBudgetMultiplier ?? 3;
|
const upgradeBudgetMultiplierVal = bodyUpgradeBudgetMultiplier ?? 3;
|
||||||
await db.insert(shopItemsTable).values({
|
await db.insert(shopItemsTable).values({
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
image: image.trim(),
|
image: image.trim(),
|
||||||
|
|
@ -35744,10 +35762,12 @@ admin.delete("/orders/:id", async ({ params, headers, body, status: status2 }) =
|
||||||
let alreadyRow = null;
|
let alreadyRow = null;
|
||||||
try {
|
try {
|
||||||
const already = await db.execute(sql`SELECT 1 FROM admin_deleted_orders WHERE original_order_id = ${orderId} AND restored = false LIMIT 1`);
|
const already = await db.execute(sql`SELECT 1 FROM admin_deleted_orders WHERE original_order_id = ${orderId} AND restored = false LIMIT 1`);
|
||||||
alreadyRow = already.rows?.[0] ?? (Array.isArray(already) ? already[0] : null);
|
const alreadyResult = already;
|
||||||
|
alreadyRow = alreadyResult.rows?.[0] ?? (Array.isArray(already) ? already[0] : null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err?.message ?? "";
|
const dbErr = err;
|
||||||
const code = err?.code ?? null;
|
const msg = dbErr?.message ?? "";
|
||||||
|
const code = dbErr?.code ?? null;
|
||||||
if (msg.includes("does not exist") || code === "42P01") {
|
if (msg.includes("does not exist") || code === "42P01") {
|
||||||
await db.execute(sql`
|
await db.execute(sql`
|
||||||
CREATE TABLE IF NOT EXISTS admin_deleted_orders (
|
CREATE TABLE IF NOT EXISTS admin_deleted_orders (
|
||||||
|
|
@ -35777,7 +35797,8 @@ admin.delete("/orders/:id", async ({ params, headers, body, status: status2 }) =
|
||||||
CREATE INDEX IF NOT EXISTS idx_admin_deleted_orders_original_order_id ON admin_deleted_orders (original_order_id);
|
CREATE INDEX IF NOT EXISTS idx_admin_deleted_orders_original_order_id ON admin_deleted_orders (original_order_id);
|
||||||
`);
|
`);
|
||||||
const already2 = await db.execute(sql`SELECT 1 FROM admin_deleted_orders WHERE original_order_id = ${orderId} AND restored = false LIMIT 1`);
|
const already2 = await db.execute(sql`SELECT 1 FROM admin_deleted_orders WHERE original_order_id = ${orderId} AND restored = false LIMIT 1`);
|
||||||
alreadyRow = already2.rows?.[0] ?? (Array.isArray(already2) ? already2[0] : null);
|
const already2Result = already2;
|
||||||
|
alreadyRow = already2Result.rows?.[0] ?? (Array.isArray(already2) ? already2[0] : null);
|
||||||
} else {
|
} else {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
@ -35824,12 +35845,13 @@ admin.delete("/orders/:id", async ({ params, headers, body, status: status2 }) =
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
try {
|
try {
|
||||||
console.error("Admin delete order error (stack):", err?.stack ?? err);
|
const e = err;
|
||||||
console.error("Admin delete order error (name):", err?.name ?? null);
|
console.error("Admin delete order error (stack):", e?.stack ?? err);
|
||||||
console.error("Admin delete order error (message):", err?.message ?? String(err));
|
console.error("Admin delete order error (name):", e?.name ?? null);
|
||||||
console.error("Admin delete order error (cause):", err?.cause ?? null);
|
console.error("Admin delete order error (message):", e?.message ?? String(err));
|
||||||
console.error("Admin delete order error (query):", err?.query ?? null);
|
console.error("Admin delete order error (cause):", e?.cause ?? null);
|
||||||
console.error("Admin delete order error (params):", err?.params ?? null);
|
console.error("Admin delete order error (query):", e?.query ?? null);
|
||||||
|
console.error("Admin delete order error (params):", e?.params ?? null);
|
||||||
try {
|
try {
|
||||||
console.error("Admin delete order error (full):", JSON.stringify(err, Object.getOwnPropertyNames(err), 2));
|
console.error("Admin delete order error (full):", JSON.stringify(err, Object.getOwnPropertyNames(err), 2));
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -35840,7 +35862,7 @@ admin.delete("/orders/:id", async ({ params, headers, body, status: status2 }) =
|
||||||
console.error("Original error:", err);
|
console.error("Original error:", err);
|
||||||
}
|
}
|
||||||
return status2(500, {
|
return status2(500, {
|
||||||
error: "Failed to delete order: " + (err?.message || String(err))
|
error: "Failed to delete order: " + (err instanceof Error ? err.message : String(err))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -35853,7 +35875,8 @@ admin.post("/orders/:id/restore", async ({ params, headers, status: status2 }) =
|
||||||
if (!Number.isInteger(originalOrderId) || originalOrderId <= 0)
|
if (!Number.isInteger(originalOrderId) || originalOrderId <= 0)
|
||||||
return status2(400, { error: "Invalid order id" });
|
return status2(400, { error: "Invalid order id" });
|
||||||
const archivedRes = await db.execute(sql`SELECT * FROM admin_deleted_orders WHERE original_order_id = ${originalOrderId} AND restored = false LIMIT 1`);
|
const archivedRes = await db.execute(sql`SELECT * FROM admin_deleted_orders WHERE original_order_id = ${originalOrderId} AND restored = false LIMIT 1`);
|
||||||
const archived = archivedRes.rows?.[0] ?? (Array.isArray(archivedRes) ? archivedRes[0] : null);
|
const archivedResult = archivedRes;
|
||||||
|
const archived = archivedResult.rows?.[0] ?? (Array.isArray(archivedRes) ? archivedRes[0] : null);
|
||||||
if (!archived)
|
if (!archived)
|
||||||
return status2(404, {
|
return status2(404, {
|
||||||
error: "Archived order not found or already restored"
|
error: "Archived order not found or already restored"
|
||||||
|
|
@ -36045,7 +36068,8 @@ admin.get("/users/:id/timeline", async ({ params, headers, status: status2 }) =>
|
||||||
type: "bonus",
|
type: "bonus",
|
||||||
amount: b.amount,
|
amount: b.amount,
|
||||||
description: b.reason,
|
description: b.reason,
|
||||||
date: b.createdAt.toISOString()
|
date: b.createdAt.toISOString(),
|
||||||
|
bonusId: b.id
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const o of shopOrders) {
|
for (const o of shopOrders) {
|
||||||
|
|
@ -36103,7 +36127,7 @@ admin.get("/users/:id/timeline", async ({ params, headers, status: status2 }) =>
|
||||||
return status2(500, { error: "Failed to fetch user timeline" });
|
return status2(500, { error: "Failed to fetch user timeline" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
admin.post("/sync-ysws", async ({ headers, set }) => {
|
admin.post("/sync-ysws", async ({ headers }) => {
|
||||||
const user2 = await requireAdmin(headers);
|
const user2 = await requireAdmin(headers);
|
||||||
if (!user2)
|
if (!user2)
|
||||||
return { error: "Unauthorized" };
|
return { error: "Unauthorized" };
|
||||||
|
|
|
||||||
|
|
@ -595,6 +595,38 @@ admin.get("/users/:id/bonuses", async ({ params, headers }) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Delete a bonus (admin only)
|
||||||
|
admin.delete("/bonuses/:id", async ({ params, headers, status }) => {
|
||||||
|
try {
|
||||||
|
const user = await requireAdmin(headers as Record<string, string>);
|
||||||
|
if (!user) return status(401, { error: "Unauthorized" });
|
||||||
|
|
||||||
|
const bonusId = parseInt(params.id);
|
||||||
|
if (!Number.isInteger(bonusId) || bonusId <= 0) {
|
||||||
|
return status(400, { error: "Invalid bonus id" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const bonus = await db
|
||||||
|
.select({ id: userBonusesTable.id })
|
||||||
|
.from(userBonusesTable)
|
||||||
|
.where(eq(userBonusesTable.id, bonusId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!bonus[0]) {
|
||||||
|
return status(404, { error: "Bonus not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(userBonusesTable)
|
||||||
|
.where(eq(userBonusesTable.id, bonusId));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
return status(500, { error: "Failed to delete bonus" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Get projects waiting for review
|
// Get projects waiting for review
|
||||||
admin.get("/reviews", async ({ headers, query }) => {
|
admin.get("/reviews", async ({ headers, query }) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -3000,6 +3032,7 @@ admin.get("/users/:id/timeline", async ({ params, headers, status }) => {
|
||||||
itemName?: string;
|
itemName?: string;
|
||||||
paid?: boolean;
|
paid?: boolean;
|
||||||
orderId?: number;
|
orderId?: number;
|
||||||
|
bonusId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const timeline: TimelineEvent[] = [];
|
const timeline: TimelineEvent[] = [];
|
||||||
|
|
@ -3022,6 +3055,7 @@ admin.get("/users/:id/timeline", async ({ params, headers, status }) => {
|
||||||
amount: b.amount,
|
amount: b.amount,
|
||||||
description: b.reason,
|
description: b.reason,
|
||||||
date: b.createdAt.toISOString(),
|
date: b.createdAt.toISOString(),
|
||||||
|
bonusId: b.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,7 @@
|
||||||
let lastDeletedError = $state<string | null>(null);
|
let lastDeletedError = $state<string | null>(null);
|
||||||
let filterItem = $state('');
|
let filterItem = $state('');
|
||||||
let filterUser = $state('');
|
let filterUser = $state('');
|
||||||
|
let filterRegion = $state<'' | 'us' | 'intl'>('');
|
||||||
|
|
||||||
let uniqueItems = $derived(
|
let uniqueItems = $derived(
|
||||||
[...new Map(orders.map((o) => [o.itemName, o.itemName])).values()].sort()
|
[...new Map(orders.map((o) => [o.itemName, o.itemName])).values()].sort()
|
||||||
|
|
@ -113,6 +114,15 @@
|
||||||
result = result.filter((o) => o.username === filterUser);
|
result = result.filter((o) => o.username === filterUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filterRegion) {
|
||||||
|
result = result.filter((o) => {
|
||||||
|
const addr = parseShippingAddress(o.shippingAddress);
|
||||||
|
const country = addr?.country?.toLowerCase().trim() ?? '';
|
||||||
|
const isUS = country === 'us' || country === 'usa' || country === 'united states' || country === 'united states of america';
|
||||||
|
return filterRegion === 'us' ? isUS : !isUS;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (searchQuery.trim()) {
|
if (searchQuery.trim()) {
|
||||||
const q = searchQuery.trim().toLowerCase();
|
const q = searchQuery.trim().toLowerCase();
|
||||||
result = result.filter((o) => {
|
result = result.filter((o) => {
|
||||||
|
|
@ -435,6 +445,18 @@
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<label for="filter-region" class="mb-1 text-xs font-bold text-gray-500 uppercase">region</label>
|
||||||
|
<select
|
||||||
|
id="filter-region"
|
||||||
|
bind:value={filterRegion}
|
||||||
|
class="cursor-pointer rounded-xl border-4 border-black px-3 py-2 font-bold transition-all duration-200 focus:border-dashed focus:outline-none {filterRegion ? 'bg-black text-white' : ''}"
|
||||||
|
>
|
||||||
|
<option value="">all regions</option>
|
||||||
|
<option value="us">US only</option>
|
||||||
|
<option value="intl">non-US</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="flex items-end gap-2">
|
<div class="flex items-end gap-2">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<label for="date-from" class="mb-1 text-xs font-bold text-gray-500 uppercase">from</label>
|
<label for="date-from" class="mb-1 text-xs font-bold text-gray-500 uppercase">from</label>
|
||||||
|
|
@ -454,13 +476,14 @@
|
||||||
class="rounded-xl border-4 border-black px-3 py-2 font-bold transition-all duration-200 focus:border-dashed focus:outline-none"
|
class="rounded-xl border-4 border-black px-3 py-2 font-bold transition-all duration-200 focus:border-dashed focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{#if dateFrom || dateTo || filterItem || filterUser}
|
{#if dateFrom || dateTo || filterItem || filterUser || filterRegion}
|
||||||
<button
|
<button
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
dateFrom = '';
|
dateFrom = '';
|
||||||
dateTo = '';
|
dateTo = '';
|
||||||
filterItem = '';
|
filterItem = '';
|
||||||
filterUser = '';
|
filterUser = '';
|
||||||
|
filterRegion = '';
|
||||||
}}
|
}}
|
||||||
class="cursor-pointer rounded-xl border-4 border-black px-3 py-2 font-bold transition-all duration-200 hover:border-dashed"
|
class="cursor-pointer rounded-xl border-4 border-black px-3 py-2 font-bold transition-all duration-200 hover:border-dashed"
|
||||||
title="clear filters"
|
title="clear filters"
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@
|
||||||
updateDescription: string | null;
|
updateDescription: string | null;
|
||||||
aiDescription: string | null;
|
aiDescription: string | null;
|
||||||
reviewerNotes: string | null;
|
reviewerNotes: string | null;
|
||||||
|
scrapsAwarded: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
|
|
@ -114,6 +115,19 @@
|
||||||
project ? Math.max(0, (hoursOverride ?? project.hours) - deductedHours) : 0
|
project ? Math.max(0, (hoursOverride ?? project.hours) - deductedHours) : 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const PHI = (1 + Math.sqrt(5)) / 2;
|
||||||
|
const MULTIPLIER = 10;
|
||||||
|
const TIER_MULTIPLIERS: Record<number, number> = { 1: 0.8, 2: 1.0, 3: 1.25, 4: 1.5 };
|
||||||
|
|
||||||
|
let isUpdate = $derived(project ? project.scrapsAwarded > 0 : false);
|
||||||
|
let previewTier = $derived(project ? (tierOverride ?? project.tier ?? 1) : 1);
|
||||||
|
let previewScraps = $derived(
|
||||||
|
Math.floor(effectiveHours * PHI * MULTIPLIER * (TIER_MULTIPLIERS[previewTier] ?? 1.0))
|
||||||
|
);
|
||||||
|
let additionalScraps = $derived(
|
||||||
|
project ? Math.max(0, previewScraps - project.scrapsAwarded) : previewScraps
|
||||||
|
);
|
||||||
|
|
||||||
let hoursOverrideError = $derived(
|
let hoursOverrideError = $derived(
|
||||||
hoursOverride !== undefined && project && hoursOverride > project.hours
|
hoursOverride !== undefined && project && hoursOverride > project.hours
|
||||||
? `Hours override cannot exceed project hours (${formatHours(project.hours)}h)`
|
? `Hours override cannot exceed project hours (${formatHours(project.hours)}h)`
|
||||||
|
|
@ -522,6 +536,29 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if isUpdate}
|
||||||
|
<div class="mt-4 rounded-lg border-2 border-dashed border-blue-500 bg-blue-50 p-4">
|
||||||
|
<p class="mb-2 flex items-center gap-1.5 text-sm font-bold text-blue-700">
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
update — scraps preview
|
||||||
|
</p>
|
||||||
|
<p class="mb-2 text-sm text-blue-700">
|
||||||
|
this is an updated project. previously awarded scraps will be subtracted from the new total.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-3 text-sm font-bold">
|
||||||
|
<span class="rounded-full border-2 border-blue-600 bg-blue-100 px-3 py-1 text-blue-800">
|
||||||
|
previously awarded: {project.scrapsAwarded} scraps
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full border-2 border-blue-600 bg-blue-100 px-3 py-1 text-blue-800">
|
||||||
|
new total: {previewScraps} scraps ({formatHours(effectiveHours)}h × tier {previewTier})
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full border-2 border-black bg-blue-200 px-3 py-1 text-black">
|
||||||
|
additional: +{additionalScraps} scraps
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="mt-4 flex flex-wrap gap-3">
|
<div class="mt-4 flex flex-wrap gap-3">
|
||||||
{#if project.githubUrl}
|
{#if project.githubUrl}
|
||||||
<a
|
<a
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@
|
||||||
updateDescription: string | null;
|
updateDescription: string | null;
|
||||||
aiDescription: string | null;
|
aiDescription: string | null;
|
||||||
reviewerNotes: string | null;
|
reviewerNotes: string | null;
|
||||||
|
scrapsAwarded: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
|
|
@ -102,6 +103,19 @@
|
||||||
let effectiveHours = $derived(
|
let effectiveHours = $derived(
|
||||||
project ? Math.max(0, (hoursOverride ?? project.hoursOverride ?? project.hours) - deductedHours) : 0
|
project ? Math.max(0, (hoursOverride ?? project.hoursOverride ?? project.hours) - deductedHours) : 0
|
||||||
);
|
);
|
||||||
|
const PHI = (1 + Math.sqrt(5)) / 2;
|
||||||
|
const MULTIPLIER = 10;
|
||||||
|
const TIER_MULTIPLIERS: Record<number, number> = { 1: 0.8, 2: 1.0, 3: 1.25, 4: 1.5 };
|
||||||
|
|
||||||
|
let isUpdate = $derived(project ? project.scrapsAwarded > 0 : false);
|
||||||
|
let previewTier = $derived(project ? (project.tierOverride ?? project.tier ?? 1) : 1);
|
||||||
|
let previewScraps = $derived(
|
||||||
|
Math.floor(effectiveHours * PHI * MULTIPLIER * (TIER_MULTIPLIERS[previewTier] ?? 1.0))
|
||||||
|
);
|
||||||
|
let additionalScraps = $derived(
|
||||||
|
project ? Math.max(0, previewScraps - project.scrapsAwarded) : previewScraps
|
||||||
|
);
|
||||||
|
|
||||||
let hoursOverrideError = $derived(
|
let hoursOverrideError = $derived(
|
||||||
hoursOverride !== undefined && project && hoursOverride > project.hours
|
hoursOverride !== undefined && project && hoursOverride > project.hours
|
||||||
? `hours override cannot exceed project hours (${formatHours(project.hours)}h)`
|
? `hours override cannot exceed project hours (${formatHours(project.hours)}h)`
|
||||||
|
|
@ -408,6 +422,29 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if isUpdate}
|
||||||
|
<div class="mt-4 rounded-lg border-2 border-dashed border-blue-500 bg-blue-50 p-4">
|
||||||
|
<p class="mb-2 flex items-center gap-1.5 text-sm font-bold text-blue-700">
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
update — scraps preview
|
||||||
|
</p>
|
||||||
|
<p class="mb-2 text-sm text-blue-700">
|
||||||
|
this is an updated project. previously awarded scraps will be subtracted from the new total.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-3 text-sm font-bold">
|
||||||
|
<span class="rounded-full border-2 border-blue-600 bg-blue-100 px-3 py-1 text-blue-800">
|
||||||
|
previously awarded: {project.scrapsAwarded} scraps
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full border-2 border-blue-600 bg-blue-100 px-3 py-1 text-blue-800">
|
||||||
|
new total: {previewScraps} scraps ({formatHours(effectiveHours)}h × tier {previewTier})
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full border-2 border-black bg-blue-200 px-3 py-1 text-black">
|
||||||
|
additional: +{additionalScraps} scraps
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="mt-4 flex flex-wrap gap-3">
|
<div class="mt-4 flex flex-wrap gap-3">
|
||||||
{#if project.githubUrl}
|
{#if project.githubUrl}
|
||||||
<a
|
<a
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
Undo2,
|
Undo2,
|
||||||
|
Trash2,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
Dices,
|
Dices,
|
||||||
FileText,
|
FileText,
|
||||||
|
|
@ -102,6 +103,7 @@
|
||||||
itemName?: string;
|
itemName?: string;
|
||||||
paid?: boolean;
|
paid?: boolean;
|
||||||
orderId?: number;
|
orderId?: number;
|
||||||
|
bonusId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let timeline = $state<TimelineEvent[]>([]);
|
let timeline = $state<TimelineEvent[]>([]);
|
||||||
|
|
@ -114,7 +116,8 @@
|
||||||
let timelineLoading = $state(false);
|
let timelineLoading = $state(false);
|
||||||
let showTimeline = $state(false);
|
let showTimeline = $state(false);
|
||||||
let undoingOrder = $state<number | null>(null);
|
let undoingOrder = $state<number | null>(null);
|
||||||
let showUndoConfirm = $state<number | null>(null);
|
let deletingBonus = $state<number | null>(null);
|
||||||
|
let showDeleteConfirm = $state<{ type: 'order'; id: number } | { type: 'bonus'; id: number } | null>(null);
|
||||||
let showUnshipConfirm = $state<number | null>(null);
|
let showUnshipConfirm = $state<number | null>(null);
|
||||||
let unshipReason = $state('');
|
let unshipReason = $state('');
|
||||||
let unshipping = $state(false);
|
let unshipping = $state(false);
|
||||||
|
|
@ -284,15 +287,13 @@
|
||||||
|
|
||||||
async function undoOrder(orderId: number) {
|
async function undoOrder(orderId: number) {
|
||||||
undoingOrder = orderId;
|
undoingOrder = orderId;
|
||||||
showUndoConfirm = null;
|
showDeleteConfirm = null;
|
||||||
try {
|
try {
|
||||||
// Directly call DELETE with a required reason payload.
|
|
||||||
// The server expects a short reason when deleting/reverting an order.
|
|
||||||
const refundRes = await fetch(`${API_URL}/admin/orders/${orderId}`, {
|
const refundRes = await fetch(`${API_URL}/admin/orders/${orderId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ reason: 'reverted via admin UI' })
|
body: JSON.stringify({ reason: 'reverted via admin timeline' })
|
||||||
});
|
});
|
||||||
const refundResult = await refundRes.json();
|
const refundResult = await refundRes.json();
|
||||||
if (refundResult.error) {
|
if (refundResult.error) {
|
||||||
|
|
@ -307,6 +308,44 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteBonus(bonusId: number) {
|
||||||
|
deletingBonus = bonusId;
|
||||||
|
showDeleteConfirm = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/admin/bonuses/${bonusId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
const result = await res.json();
|
||||||
|
if (result.error) {
|
||||||
|
console.error(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fetchTimeline();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to delete bonus:', e);
|
||||||
|
} finally {
|
||||||
|
deletingBonus = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmTimelineDelete(event: TimelineEvent) {
|
||||||
|
if (event.type === 'bonus' && event.bonusId) {
|
||||||
|
showDeleteConfirm = { type: 'bonus', id: event.bonusId };
|
||||||
|
} else if (event.type.startsWith('shop_') && event.orderId) {
|
||||||
|
showDeleteConfirm = { type: 'order', id: event.orderId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeTimelineDelete() {
|
||||||
|
if (!showDeleteConfirm) return;
|
||||||
|
if (showDeleteConfirm.type === 'bonus') {
|
||||||
|
deleteBonus(showDeleteConfirm.id);
|
||||||
|
} else {
|
||||||
|
undoOrder(showDeleteConfirm.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function unshipProject(projectId: number) {
|
async function unshipProject(projectId: number) {
|
||||||
unshipping = true;
|
unshipping = true;
|
||||||
try {
|
try {
|
||||||
|
|
@ -835,31 +874,19 @@
|
||||||
<p class="truncate text-sm text-gray-700">{event.description}</p>
|
<p class="truncate text-sm text-gray-700">{event.description}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex shrink-0 items-center gap-2">
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
{#if event.type.startsWith('shop_') && event.orderId}
|
{#if (event.type.startsWith('shop_') && event.orderId) || (event.type === 'bonus' && event.bonusId)}
|
||||||
{#if showUndoConfirm === event.orderId}
|
<button
|
||||||
<div class="flex items-center gap-1">
|
onclick={() => confirmTimelineDelete(event)}
|
||||||
<button
|
disabled={undoingOrder === event.orderId || deletingBonus === event.bonusId}
|
||||||
onclick={() => undoOrder(event.orderId ?? 0)}
|
class="cursor-pointer rounded-full border-2 border-red-600 px-2 py-1 text-xs font-bold text-red-600 transition-all duration-200 hover:border-dashed disabled:opacity-50"
|
||||||
disabled={undoingOrder === event.orderId}
|
title="delete"
|
||||||
class="cursor-pointer rounded-full bg-red-600 px-2 py-1 text-xs font-bold text-white transition-all duration-200 hover:bg-red-700 disabled:opacity-50"
|
>
|
||||||
>
|
{#if undoingOrder === event.orderId || deletingBonus === event.bonusId}
|
||||||
{undoingOrder === event.orderId ? '...' : 'confirm'}
|
...
|
||||||
</button>
|
{:else}
|
||||||
<button
|
<Trash2 size={12} />
|
||||||
onclick={() => (showUndoConfirm = null)}
|
{/if}
|
||||||
class="cursor-pointer rounded-full border-2 border-black px-2 py-1 text-xs font-bold transition-all duration-200 hover:border-dashed"
|
</button>
|
||||||
>
|
|
||||||
cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<button
|
|
||||||
onclick={() => (showUndoConfirm = event.orderId ?? null)}
|
|
||||||
class="cursor-pointer rounded-full border-2 border-red-600 px-2 py-1 text-xs font-bold text-red-600 transition-all duration-200 hover:border-dashed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Undo2 size={12} />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<p class="font-bold {getTimelineColor(event.type, event.amount)}">
|
<p class="font-bold {getTimelineColor(event.type, event.amount)}">
|
||||||
|
|
@ -999,3 +1026,41 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Timeline Delete Confirmation Modal -->
|
||||||
|
{#if showDeleteConfirm}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||||
|
onclick={(e) => e.target === e.currentTarget && (showDeleteConfirm = null)}
|
||||||
|
onkeydown={(e) => e.key === 'Escape' && (showDeleteConfirm = null)}
|
||||||
|
role="dialog"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div class="w-full max-w-md rounded-2xl border-4 border-black bg-white p-6">
|
||||||
|
<h2 class="mb-4 text-2xl font-bold">
|
||||||
|
delete {showDeleteConfirm.type === 'bonus' ? 'bonus' : 'order'}
|
||||||
|
</h2>
|
||||||
|
<p class="mb-6 text-gray-600">
|
||||||
|
{#if showDeleteConfirm.type === 'bonus'}
|
||||||
|
this will permanently delete this bonus entry from the database. the user's balance will be recalculated.
|
||||||
|
{:else}
|
||||||
|
this will permanently delete this order and all associated records (refinery upgrades, rolls, penalties). item stock will be restored.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button
|
||||||
|
onclick={() => (showDeleteConfirm = null)}
|
||||||
|
class="flex-1 cursor-pointer rounded-full border-4 border-black px-4 py-2 font-bold transition-all duration-200 hover:border-dashed"
|
||||||
|
>
|
||||||
|
cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={executeTimelineDelete}
|
||||||
|
class="flex-1 cursor-pointer rounded-full border-4 border-red-600 bg-red-600 px-4 py-2 font-bold text-white transition-all duration-200 hover:border-dashed disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue