style(frontend): format frontend feature components
This commit is contained in:
parent
7db1f648bf
commit
a21564a78d
78 changed files with 1024 additions and 1158 deletions
|
|
@ -10,9 +10,8 @@ export function LandingPage() {
|
|||
<AppLogo />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Classified operations portal for Polaris Task Force, an Arma 3 unit.
|
||||
Log in to access the operations dashboard, intelligence, and
|
||||
logistics.
|
||||
Classified operations portal for Polaris Task Force, an Arma 3 unit. Log in to access the
|
||||
operations dashboard, intelligence, and logistics.
|
||||
</p>
|
||||
<Button asChild className="mt-2">
|
||||
<Link href="/login">Log in to access the portal</Link>
|
||||
|
|
|
|||
|
|
@ -29,9 +29,7 @@ export function LoginForm() {
|
|||
const body = await res.json().catch(() => null);
|
||||
|
||||
if (!res.ok) {
|
||||
setError(
|
||||
body?.errors?.[0]?.message ?? "Invalid username or password.",
|
||||
);
|
||||
setError(body?.errors?.[0]?.message ?? "Invalid username or password.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,11 +12,7 @@ import {
|
|||
} from "@/components/ui/item";
|
||||
import { LandmarkIcon, UsersIcon, WalletIcon } from "lucide-react";
|
||||
import { formatAmount } from "@/lib/banking/format";
|
||||
import {
|
||||
accountStatusBase,
|
||||
accountStatusClass,
|
||||
type AccountStatus,
|
||||
} from "@/lib/banking/ui";
|
||||
import { type AccountStatus, accountStatusBase, accountStatusClass } from "@/lib/banking/ui";
|
||||
|
||||
const accountTypeMeta = {
|
||||
treasury: {
|
||||
|
|
|
|||
|
|
@ -3,18 +3,9 @@
|
|||
import Link from "next/link";
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
import type { BankAccount, LedgerEntry } from "@/payload-types";
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemHeader,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import { formatAmount } from "@/lib/banking/format";
|
||||
import {
|
||||
accountStatusBase,
|
||||
accountStatusClass,
|
||||
type AccountStatus,
|
||||
} from "@/lib/banking/ui";
|
||||
import { type AccountStatus, accountStatusBase, accountStatusClass } from "@/lib/banking/ui";
|
||||
import { accountTypeInfo } from "@/components/frontend/banking/AccountCard";
|
||||
import { BankTransactionDialog } from "@/components/frontend/banking/BankTransactionDialog";
|
||||
import { LedgerTable } from "@/components/frontend/banking/LedgerTable";
|
||||
|
|
@ -26,12 +17,7 @@ interface AccountDetailProps {
|
|||
canTransact: boolean;
|
||||
}
|
||||
|
||||
export function AccountDetail({
|
||||
account,
|
||||
ledger,
|
||||
otherAccounts,
|
||||
canTransact,
|
||||
}: AccountDetailProps) {
|
||||
export function AccountDetail({ account, ledger, otherAccounts, canTransact }: AccountDetailProps) {
|
||||
const meta = accountTypeInfo(account.accountType);
|
||||
const Icon = meta.icon;
|
||||
const currency = typeof account.currency === "object" ? account.currency : null;
|
||||
|
|
@ -85,10 +71,7 @@ export function AccountDetail({
|
|||
</div>
|
||||
|
||||
{canTransact && (
|
||||
<BankTransactionDialog
|
||||
accountId={account.id}
|
||||
otherAccounts={otherAccounts}
|
||||
/>
|
||||
<BankTransactionDialog accountId={account.id} otherAccounts={otherAccounts} />
|
||||
)}
|
||||
|
||||
<Item variant="outline">
|
||||
|
|
|
|||
|
|
@ -2,19 +2,15 @@
|
|||
|
||||
import Link from "next/link";
|
||||
import type { BankAccount, BankTransaction, Resource, User } from "@/payload-types";
|
||||
import {
|
||||
ArrowDownLeftIcon,
|
||||
ArrowRightLeftIcon,
|
||||
ArrowUpRightIcon,
|
||||
} from "lucide-react";
|
||||
import { ArrowDownLeftIcon, ArrowRightLeftIcon, ArrowUpRightIcon } from "lucide-react";
|
||||
import { currencyLabel } from "@/lib/banking/format";
|
||||
import {
|
||||
formatTxTimestamp,
|
||||
txAmountClass,
|
||||
txAmountPrefix,
|
||||
type TxFlow,
|
||||
txTypeColor,
|
||||
txTypeFlow,
|
||||
type TxFlow,
|
||||
} from "@/lib/banking/ui";
|
||||
|
||||
interface ActivityFeedProps {
|
||||
|
|
@ -45,9 +41,10 @@ const txTypeLabel: Record<string, string> = {
|
|||
adjustment: "ADJUST",
|
||||
};
|
||||
|
||||
function resolveAccountLabel(
|
||||
account: (number | null) | BankAccount | undefined,
|
||||
): { label: string; id: number | null } {
|
||||
function resolveAccountLabel(account: (number | null) | BankAccount | undefined): {
|
||||
label: string;
|
||||
id: number | null;
|
||||
} {
|
||||
if (!account) return { label: "—", id: null };
|
||||
if (typeof account === "object") {
|
||||
return { label: account.name ?? `#${account.id}`, id: account.id };
|
||||
|
|
@ -88,10 +85,7 @@ export function ActivityFeed({ transactions, currencyResource }: ActivityFeedPro
|
|||
const to = resolveAccountLabel(tx.toAccount);
|
||||
const actor = resolveActorLabel(tx.actor);
|
||||
const targetAccountId = to.id ?? from.id;
|
||||
const href =
|
||||
targetAccountId != null
|
||||
? `/logistics/banking/${targetAccountId}`
|
||||
: null;
|
||||
const href = targetAccountId != null ? `/logistics/banking/${targetAccountId}` : null;
|
||||
const amount = tx.amount ?? 0;
|
||||
const signed = `${txAmountPrefix(flow)}${amount.toLocaleString()}`;
|
||||
|
||||
|
|
@ -113,23 +107,15 @@ export function ActivityFeed({ transactions, currencyResource }: ActivityFeedPro
|
|||
{to.label}
|
||||
</div>
|
||||
{tx.memo && (
|
||||
<p className="text-[11px] text-muted-foreground/70 italic truncate">
|
||||
{tx.memo}
|
||||
</p>
|
||||
)}
|
||||
{actor && (
|
||||
<p className="text-[10px] text-muted-foreground/50">by {actor}</p>
|
||||
<p className="text-[11px] text-muted-foreground/70 italic truncate">{tx.memo}</p>
|
||||
)}
|
||||
{actor && <p className="text-[10px] text-muted-foreground/50">by {actor}</p>}
|
||||
</div>
|
||||
<div
|
||||
className={`font-mono text-sm tabular-nums shrink-0 ml-auto pr-1 ${txAmountClass(flow)}`}
|
||||
>
|
||||
{signed}
|
||||
{cur && (
|
||||
<span className="ml-1 text-[10px] text-muted-foreground/60">
|
||||
{cur}
|
||||
</span>
|
||||
)}
|
||||
{cur && <span className="ml-1 text-[10px] text-muted-foreground/60">{cur}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -59,10 +59,7 @@ interface BankTransactionDialogProps {
|
|||
otherAccounts: { id: number; name: string }[];
|
||||
}
|
||||
|
||||
export function BankTransactionDialog({
|
||||
accountId,
|
||||
otherAccounts,
|
||||
}: BankTransactionDialogProps) {
|
||||
export function BankTransactionDialog({ accountId, otherAccounts }: BankTransactionDialogProps) {
|
||||
const router = useRouter();
|
||||
const [mode, setMode] = useState<Mode>("deposit");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
|
|
|||
|
|
@ -3,12 +3,7 @@
|
|||
import type { ReactNode } from "react";
|
||||
import type { BankAccount, BankTransaction, Resource } from "@/payload-types";
|
||||
import { LandmarkIcon } from "lucide-react";
|
||||
import {
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TabsContent,
|
||||
} from "@/components/ui/tabs";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { AccountCard } from "@/components/frontend/banking/AccountCard";
|
||||
import { CreateAccountDialog } from "@/components/frontend/banking/CreateAccountDialog";
|
||||
import { WalletHero } from "@/components/frontend/banking/WalletHero";
|
||||
|
|
@ -46,9 +41,7 @@ function Section({
|
|||
{count === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">None.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
{children}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">{children}</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
|
@ -69,18 +62,13 @@ export function BankingOverview({
|
|||
const myAccount = currentUser
|
||||
? personal.find((a) => {
|
||||
if (!a.ownerUser) return false;
|
||||
const ownerId =
|
||||
typeof a.ownerUser === "object" ? a.ownerUser.id : a.ownerUser;
|
||||
const ownerId = typeof a.ownerUser === "object" ? a.ownerUser.id : a.ownerUser;
|
||||
return ownerId === currentUser.id;
|
||||
})
|
||||
: undefined;
|
||||
|
||||
// Regular users see only their own personal account; managers see everything.
|
||||
const visiblePersonal = isManager
|
||||
? personal
|
||||
: myAccount
|
||||
? [myAccount]
|
||||
: [];
|
||||
const visiblePersonal = isManager ? personal : myAccount ? [myAccount] : [];
|
||||
|
||||
const unitFundsTotal = [...treasury, ...factionAccounts].reduce(
|
||||
(sum, a) => sum + (a.balance ?? 0),
|
||||
|
|
@ -88,8 +76,7 @@ export function BankingOverview({
|
|||
);
|
||||
|
||||
const visibleAccountsCount =
|
||||
(isManager ? treasury.length + factionAccounts.length : 0) +
|
||||
visiblePersonal.length;
|
||||
(isManager ? treasury.length + factionAccounts.length : 0) + visiblePersonal.length;
|
||||
|
||||
const tickerCurrency = (label: string): string =>
|
||||
label.length > 8 ? `${label.slice(0, 8)}…` : label;
|
||||
|
|
@ -104,10 +91,7 @@ export function BankingOverview({
|
|||
|
||||
<Tabs defaultValue="activity" className="gap-4">
|
||||
<div className="flex flex-wrap items-end justify-between gap-2">
|
||||
<TabsList
|
||||
variant="line"
|
||||
className="grid w-full max-w-sm grid-cols-2"
|
||||
>
|
||||
<TabsList variant="line" className="grid w-full max-w-sm grid-cols-2">
|
||||
<TabsTrigger
|
||||
value="activity"
|
||||
className="font-mono text-[10px] uppercase tracking-wider"
|
||||
|
|
@ -131,10 +115,7 @@ export function BankingOverview({
|
|||
</div>
|
||||
|
||||
<TabsContent value="activity" className="m-0">
|
||||
<ActivityFeed
|
||||
transactions={recentTransactions}
|
||||
currencyResource={currencyResource}
|
||||
/>
|
||||
<ActivityFeed transactions={recentTransactions} currencyResource={currencyResource} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="accounts" className="m-0 flex flex-col gap-6">
|
||||
|
|
|
|||
|
|
@ -94,10 +94,7 @@ export function CreateAccountDialog({ isManager, factions }: CreateAccountDialog
|
|||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Account type</Label>
|
||||
<Select
|
||||
value={accountType}
|
||||
onValueChange={(v) => setAccountType(v as AccountType)}
|
||||
>
|
||||
<Select value={accountType} onValueChange={(v) => setAccountType(v as AccountType)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
|
|
|
|||
|
|
@ -20,9 +20,7 @@ const txTypeLabel: Record<string, string> = {
|
|||
|
||||
export function LedgerTable({ entries }: { entries: LedgerEntry[] }) {
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground italic">No ledger entries yet.</p>
|
||||
);
|
||||
return <p className="text-sm text-muted-foreground italic">No ledger entries yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -59,17 +57,11 @@ export function LedgerTable({ entries }: { entries: LedgerEntry[] }) {
|
|||
// entry's actual sign — it reflects the perspective of THIS
|
||||
// account, which the canonical flow cannot know.
|
||||
const useEntrySign = amount !== 0 && Math.sign(amount) < 0;
|
||||
const prefix = useEntrySign
|
||||
? txAmountPrefix("out")
|
||||
: txAmountPrefix(flow);
|
||||
const amountCls = useEntrySign
|
||||
? txAmountClass("out")
|
||||
: txAmountClass(flow);
|
||||
const prefix = useEntrySign ? txAmountPrefix("out") : txAmountPrefix(flow);
|
||||
const amountCls = useEntrySign ? txAmountClass("out") : txAmountClass(flow);
|
||||
return (
|
||||
<tr key={entry.id} className="border-b border-border/50">
|
||||
<td className="px-4 py-2 text-muted-foreground">
|
||||
{formatDate(entry.timestamp)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{formatDate(entry.timestamp)}</td>
|
||||
<td className={`px-4 py-2 ${colorCls} font-medium`}>{label}</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{entry.memo}</td>
|
||||
<td className={`px-4 py-2 text-right font-mono ${amountCls}`}>
|
||||
|
|
|
|||
|
|
@ -30,16 +30,9 @@ export function MyWalletCard() {
|
|||
<WalletIcon className="size-4 text-emerald-400" />
|
||||
<p className="text-sm font-semibold">My wallet</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You don't have a personal account yet.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">You don't have a personal account yet.</p>
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={handleCreate}
|
||||
disabled={loading}
|
||||
>
|
||||
<Button size="sm" className="w-fit" onClick={handleCreate} disabled={loading}>
|
||||
{loading ? "Creating..." : "Create wallet"}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@ import type { BankAccount, Resource } from "@/payload-types";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { formatAmount } from "@/lib/banking/format";
|
||||
import {
|
||||
type AccountStatus,
|
||||
accountStatusBase,
|
||||
accountStatusClass,
|
||||
formatLastActivity,
|
||||
type AccountStatus,
|
||||
} from "@/lib/banking/ui";
|
||||
import { BankTransactionDialog } from "@/components/frontend/banking/BankTransactionDialog";
|
||||
import { ensureMyAccount } from "@/app/(frontend)/logistics/banking/actions";
|
||||
|
|
@ -25,11 +25,7 @@ interface WalletHeroProps {
|
|||
// Shared terminal-aesthetic class for small uppercase mono labels in the hero.
|
||||
const labelCls = "font-mono text-[10px] uppercase tracking-wider text-muted-foreground";
|
||||
|
||||
export function WalletHero({
|
||||
account,
|
||||
currentUser,
|
||||
currencyResource,
|
||||
}: WalletHeroProps) {
|
||||
export function WalletHero({ account, currentUser, currencyResource }: WalletHeroProps) {
|
||||
const router = useRouter();
|
||||
const [initializing, setInitializing] = useState(false);
|
||||
const [initError, setInitError] = useState<string | null>(null);
|
||||
|
|
@ -56,9 +52,7 @@ export function WalletHero({
|
|||
<WalletIcon className="size-4 text-blue-400/80" />
|
||||
<span className={labelCls}><OPERATOR WALLET></span>
|
||||
</div>
|
||||
<p className="font-mono text-sm text-muted-foreground/80">
|
||||
{"> WALLET NOT INITIALIZED"}
|
||||
</p>
|
||||
<p className="font-mono text-sm text-muted-foreground/80">{"> WALLET NOT INITIALIZED"}</p>
|
||||
<p className="font-mono text-xs text-muted-foreground/60">
|
||||
Run initialization to provision your personal account.
|
||||
</p>
|
||||
|
|
@ -85,8 +79,7 @@ export function WalletHero({
|
|||
|
||||
const accountId = account.id;
|
||||
const lastActivity = formatLastActivity(account.updatedAt);
|
||||
const currency =
|
||||
typeof account.currency === "object" ? account.currency : currencyResource;
|
||||
const currency = typeof account.currency === "object" ? account.currency : currencyResource;
|
||||
const operatorLabel = currentUser.displayName ?? currentUser.username;
|
||||
const statusKey = (account.status ?? "open") as AccountStatus;
|
||||
|
||||
|
|
@ -106,9 +99,7 @@ export function WalletHero({
|
|||
<span className={labelCls}><OPERATOR WALLET></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm text-muted-foreground/90">
|
||||
@{operatorLabel}
|
||||
</span>
|
||||
<span className="font-mono text-sm text-muted-foreground/90">@{operatorLabel}</span>
|
||||
<span
|
||||
className={`${accountStatusBase} ${accountStatusClass[statusKey] ?? accountStatusClass.open}`}
|
||||
>
|
||||
|
|
@ -131,10 +122,7 @@ export function WalletHero({
|
|||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 pt-3 border-t border-blue-500/15">
|
||||
<BankTransactionDialog
|
||||
accountId={accountId}
|
||||
otherAccounts={otherAccounts}
|
||||
/>
|
||||
<BankTransactionDialog accountId={accountId} otherAccounts={otherAccounts} />
|
||||
<Link href={`/logistics/banking/${accountId}`} prefetch={false}>
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,17 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Bolt, Send, Shield, Users, UserCog, Warehouse, Crosshair, Backpack, LifeBuoy } from "lucide-react";
|
||||
import {
|
||||
Backpack,
|
||||
Bolt,
|
||||
Crosshair,
|
||||
LifeBuoy,
|
||||
Send,
|
||||
Shield,
|
||||
UserCog,
|
||||
Users,
|
||||
Warehouse,
|
||||
} from "lucide-react";
|
||||
|
||||
import { NavCore } from "@/components/frontend/blocks/NavCore";
|
||||
import { NavSecondary } from "@/components/frontend/blocks/NavSecondary";
|
||||
|
|
|
|||
|
|
@ -1,13 +1,9 @@
|
|||
"use client"
|
||||
"use client";
|
||||
|
||||
import Link from "next/link"
|
||||
import { ChevronRight, type LucideIcon } from "lucide-react"
|
||||
import Link from "next/link";
|
||||
import { ChevronRight, type LucideIcon } from "lucide-react";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
|
|
@ -17,24 +13,24 @@ import {
|
|||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
} from "@/components/ui/sidebar"
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
export function NavCore({
|
||||
label,
|
||||
items,
|
||||
}: {
|
||||
label: string,
|
||||
label: string;
|
||||
items: {
|
||||
title: string
|
||||
url: string
|
||||
icon: LucideIcon
|
||||
isActive?: boolean
|
||||
title: string;
|
||||
url: string;
|
||||
icon: LucideIcon;
|
||||
isActive?: boolean;
|
||||
items?: {
|
||||
title: string
|
||||
url: string
|
||||
icon?: LucideIcon
|
||||
}[]
|
||||
}[]
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: LucideIcon;
|
||||
}[];
|
||||
}[];
|
||||
}) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,7 @@
|
|||
"use client"
|
||||
"use client";
|
||||
|
||||
import Link from "next/link"
|
||||
import {
|
||||
Folder,
|
||||
MoreHorizontal,
|
||||
Share,
|
||||
Trash2,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
import Link from "next/link";
|
||||
import { Folder, type LucideIcon, MoreHorizontal, Share, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -15,7 +9,7 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
|
|
@ -24,18 +18,18 @@ import {
|
|||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
export function NavProjects({
|
||||
projects,
|
||||
}: {
|
||||
projects: {
|
||||
name: string
|
||||
url: string
|
||||
icon: LucideIcon
|
||||
}[]
|
||||
name: string;
|
||||
url: string;
|
||||
icon: LucideIcon;
|
||||
}[];
|
||||
}) {
|
||||
const { isMobile } = useSidebar()
|
||||
const { isMobile } = useSidebar();
|
||||
|
||||
return (
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
|
|
@ -86,5 +80,5 @@ export function NavProjects({
|
|||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import { type LucideIcon } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { type LucideIcon } from "lucide-react";
|
||||
|
||||
import {
|
||||
SidebarGroup,
|
||||
|
|
@ -8,17 +8,17 @@ import {
|
|||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar"
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
export function NavSecondary({
|
||||
items,
|
||||
...props
|
||||
}: {
|
||||
items: {
|
||||
title: string
|
||||
url: string
|
||||
icon: LucideIcon
|
||||
}[]
|
||||
title: string;
|
||||
url: string;
|
||||
icon: LucideIcon;
|
||||
}[];
|
||||
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
|
||||
return (
|
||||
<SidebarGroup {...props}>
|
||||
|
|
@ -37,5 +37,5 @@ export function NavSecondary({
|
|||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
BadgeCheck,
|
||||
Bell,
|
||||
Bird,
|
||||
ChevronsUpDown,
|
||||
CreditCard,
|
||||
FileTextIcon,
|
||||
LogOut,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { BadgeCheck, Bird, ChevronsUpDown, FileTextIcon, LogOut } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
|
|
|||
|
|
@ -15,9 +15,7 @@ const XPDisplay = ({ xpLevel }: { xpLevel: ResolvedLevel }) => {
|
|||
<SparkleIcon />
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="uppercase">{levelName}</span>
|
||||
<span className="text-2xl font-bold uppercase">
|
||||
{xp.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-2xl font-bold uppercase">{xp.toLocaleString()}</span>
|
||||
</div>
|
||||
</ItemTitle>
|
||||
</ItemHeader>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,5 @@
|
|||
import Link from "next/link";
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import { ChevronRightIcon, FlagIcon } from "lucide-react";
|
||||
|
||||
interface CampaignEntry {
|
||||
|
|
@ -35,9 +29,7 @@ export function ActiveCampaigns({ campaigns }: ActiveCampaignsProps) {
|
|||
</div>
|
||||
</ItemHeader>
|
||||
<ItemContent>
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No active campaigns.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground italic">No active campaigns.</p>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
);
|
||||
|
|
@ -77,9 +69,7 @@ export function ActiveCampaigns({ campaigns }: ActiveCampaignsProps) {
|
|||
{campaign.status === "inProgress" ? "Active" : campaign.status}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{campaign.summary}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate">{campaign.summary}</span>
|
||||
</div>
|
||||
<ChevronRightIcon className="size-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,7 @@
|
|||
import Link from "next/link";
|
||||
import type { Campaign, Map } from "@/payload-types";
|
||||
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import {
|
||||
CalendarDaysIcon,
|
||||
ClockIcon,
|
||||
CrosshairIcon,
|
||||
MapPinIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import { CalendarDaysIcon, ClockIcon, CrosshairIcon, MapPinIcon, UsersIcon } from "lucide-react";
|
||||
|
||||
interface MissionBriefingProps {
|
||||
mission: {
|
||||
|
|
@ -60,18 +47,14 @@ export function MissionBriefing({ mission }: MissionBriefingProps) {
|
|||
</div>
|
||||
</ItemHeader>
|
||||
<ItemContent>
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No missions currently scheduled.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground italic">No missions currently scheduled.</p>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
);
|
||||
}
|
||||
|
||||
const map =
|
||||
typeof mission.classification.map === "object"
|
||||
? (mission.classification.map as Map)
|
||||
: null;
|
||||
typeof mission.classification.map === "object" ? (mission.classification.map as Map) : null;
|
||||
const campaign =
|
||||
typeof mission.classification.campaign === "object"
|
||||
? (mission.classification.campaign as Campaign)
|
||||
|
|
@ -89,10 +72,7 @@ export function MissionBriefing({ mission }: MissionBriefingProps) {
|
|||
});
|
||||
const durationHours = Math.floor(mission.classification.estimatedDuration / 60);
|
||||
const durationMins = mission.classification.estimatedDuration % 60;
|
||||
const durationStr =
|
||||
durationMins > 0
|
||||
? `${durationHours}h ${durationMins}m`
|
||||
: `${durationHours}h`;
|
||||
const durationStr = durationMins > 0 ? `${durationHours}h ${durationMins}m` : `${durationHours}h`;
|
||||
|
||||
return (
|
||||
<Item variant="outline">
|
||||
|
|
@ -112,9 +92,7 @@ export function MissionBriefing({ mission }: MissionBriefingProps) {
|
|||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{mission.codeName}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">{mission.codeName}</span>
|
||||
<span
|
||||
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${statusColors[mission.ownershipAndStatus.status] ?? "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
|
|
@ -125,9 +103,7 @@ export function MissionBriefing({ mission }: MissionBriefingProps) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{mission.summary}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">{mission.summary}</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
|
|
@ -150,9 +126,7 @@ export function MissionBriefing({ mission }: MissionBriefingProps) {
|
|||
{mission.missionRoles.maxPlayers} slots
|
||||
</span>
|
||||
)}
|
||||
{campaign && (
|
||||
<span className="text-primary">{campaign.name}</span>
|
||||
)}
|
||||
{campaign && <span className="text-primary">{campaign.name}</span>}
|
||||
</div>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,8 @@
|
|||
import Link from "next/link";
|
||||
import type { Rank, Media, Assignment } from "@/payload-types";
|
||||
import type { Assignment, Rank } from "@/payload-types";
|
||||
import type { ResolvedLevel } from "@/utils/xp/resolveLevel";
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import { BadgeCheckIcon, ShieldIcon, SparkleIcon, UserIcon } from "lucide-react";
|
||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import { UserIcon } from "lucide-react";
|
||||
|
||||
interface ProfileSummaryProps {
|
||||
displayName: string;
|
||||
|
|
@ -49,9 +43,7 @@ export function ProfileSummary({
|
|||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<h3 className="text-lg font-bold tracking-tight uppercase">
|
||||
{displayName}
|
||||
</h3>
|
||||
<h3 className="text-lg font-bold tracking-tight uppercase">{displayName}</h3>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{rank.name}</span>
|
||||
<span className="text-border">|</span>
|
||||
|
|
@ -83,9 +75,7 @@ export function ProfileSummary({
|
|||
>
|
||||
{a.callsign ? `[${a.callsign}] ` : ""}
|
||||
{a.name}
|
||||
<span className="text-muted-foreground ml-1.5">
|
||||
({a.type})
|
||||
</span>
|
||||
<span className="text-muted-foreground ml-1.5">({a.type})</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,5 @@
|
|||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import {
|
||||
Building2Icon,
|
||||
CrosshairIcon,
|
||||
ShieldIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from "@/components/ui/item";
|
||||
import { Building2Icon, CrosshairIcon, ShieldIcon, UsersIcon } from "lucide-react";
|
||||
|
||||
interface QuickStatsProps {
|
||||
activeCampaigns: number;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,5 @@
|
|||
import type { User, GameStructure } from "@/payload-types";
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import type { GameStructure, User } from "@/payload-types";
|
||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import { BookOpenTextIcon, ScrollTextIcon } from "lucide-react";
|
||||
import { formatType } from "@/utils/event-log/formatType";
|
||||
|
||||
|
|
@ -48,9 +42,7 @@ export function RecentEvents({ events, currentUserId }: RecentEventsProps) {
|
|||
</div>
|
||||
</ItemHeader>
|
||||
<ItemContent>
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No recent events recorded.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground italic">No recent events recorded.</p>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
);
|
||||
|
|
@ -71,12 +63,9 @@ export function RecentEvents({ events, currentUserId }: RecentEventsProps) {
|
|||
typeof event.actor === "object" && event.actor !== null
|
||||
? (event.actor as User)
|
||||
: null;
|
||||
const actorId =
|
||||
actor?.id ??
|
||||
(typeof event.actor === "number" ? event.actor : null);
|
||||
const actorId = actor?.id ?? (typeof event.actor === "number" ? event.actor : null);
|
||||
const isYou = actorId != null && actorId === currentUserId;
|
||||
const actorName =
|
||||
actor?.displayName ?? actor?.username ?? null;
|
||||
const actorName = actor?.displayName ?? actor?.username ?? null;
|
||||
const actorLabel = isYou ? "You" : actorName;
|
||||
const isNarrative = event.system === false;
|
||||
|
||||
|
|
@ -90,9 +79,7 @@ export function RecentEvents({ events, currentUserId }: RecentEventsProps) {
|
|||
)}
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="tabular-nums shrink-0">
|
||||
{timeAgo(event.timestamp)}
|
||||
</span>
|
||||
<span className="tabular-nums shrink-0">{timeAgo(event.timestamp)}</span>
|
||||
{actorLabel && (
|
||||
<span className={`font-medium ${isYou ? "text-foreground" : ""}`}>
|
||||
{actorLabel}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,7 @@
|
|||
import Link from "next/link";
|
||||
import type { Campaign, Map } from "@/payload-types";
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import { CalendarDaysIcon, ChevronRightIcon, CrosshairIcon } from "lucide-react";
|
||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import { ChevronRightIcon, CrosshairIcon } from "lucide-react";
|
||||
|
||||
interface MissionEntry {
|
||||
id: number;
|
||||
|
|
@ -38,9 +32,7 @@ export function UpcomingMissions({ missions }: UpcomingMissionsProps) {
|
|||
</div>
|
||||
</ItemHeader>
|
||||
<ItemContent>
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No missions scheduled.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground italic">No missions scheduled.</p>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
);
|
||||
|
|
@ -98,9 +90,7 @@ export function UpcomingMissions({ missions }: UpcomingMissionsProps) {
|
|||
{mission.name}
|
||||
</span>
|
||||
{campaign && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{campaign.name}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">{campaign.name}</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRightIcon className="size-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity mt-1 shrink-0" />
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { submitFlappyScore, type FlappyStats } from "@/app/(frontend)/flappy/actions";
|
||||
import { type FlappyStats, submitFlappyScore } from "@/app/(frontend)/flappy/actions";
|
||||
import { playFlap, playHit, playXp } from "./sounds";
|
||||
|
||||
const CANVAS_W = 420;
|
||||
|
|
|
|||
|
|
@ -226,8 +226,9 @@ export function CreateTicketDialog() {
|
|||
<AlertTitle className="text-lg">Important!</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
Marking this ticket as critical means it <strong>requires immediate attention</strong> and may
|
||||
impact the stability or functionality of the system.
|
||||
Marking this ticket as critical means it{" "}
|
||||
<strong>requires immediate attention</strong> and may impact the stability or
|
||||
functionality of the system.
|
||||
</p>
|
||||
<p>
|
||||
Please ensure that the description clearly outlines the issue and any potential
|
||||
|
|
|
|||
|
|
@ -9,10 +9,7 @@ import {
|
|||
ItemHeader,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import {
|
||||
TicketBadges,
|
||||
TicketCategoryBadge,
|
||||
} from "@/components/frontend/helpdesk/TicketBadges";
|
||||
import { TicketBadges, TicketCategoryBadge } from "@/components/frontend/helpdesk/TicketBadges";
|
||||
|
||||
interface TicketCardProps {
|
||||
readonly ticket: Ticket;
|
||||
|
|
@ -50,7 +47,10 @@ export function TicketCard({ ticket }: TicketCardProps) {
|
|||
|
||||
return (
|
||||
<Link href={`/helpdesk/${ticket.id}`} className="group block h-full">
|
||||
<Item variant="outline" className="h-full min-h-48 transition-colors group-hover:bg-accent/50">
|
||||
<Item
|
||||
variant="outline"
|
||||
className="h-full min-h-48 transition-colors group-hover:bg-accent/50"
|
||||
>
|
||||
<ItemHeader className="items-start">
|
||||
<TicketCategoryBadge category={ticket.category} />
|
||||
<span className="font-mono text-[10px] text-muted-foreground">#{ticket.id}</span>
|
||||
|
|
@ -59,9 +59,7 @@ export function TicketCard({ ticket }: TicketCardProps) {
|
|||
<ItemTitle className="line-clamp-2 min-w-0 max-w-full text-base font-semibold">
|
||||
{ticket.title}
|
||||
</ItemTitle>
|
||||
<ItemDescription>
|
||||
{description || "No description provided."}
|
||||
</ItemDescription>
|
||||
<ItemDescription>{description || "No description provided."}</ItemDescription>
|
||||
<TicketBadges ticket={ticket} />
|
||||
</ItemContent>
|
||||
<ItemFooter className="w-full flex-wrap items-end">
|
||||
|
|
|
|||
|
|
@ -9,16 +9,18 @@ import type { Ticket, User } from "@/payload-types";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { TicketBadges, TicketCategoryBadge } from "@/components/frontend/helpdesk/TicketBadges";
|
||||
import { TicketTimeline } from "@/components/frontend/helpdesk/TicketTimeline";
|
||||
import { replyToTicket, updateTicketStatus, assignTicket } from "@/app/(frontend)/helpdesk/actions";
|
||||
import {
|
||||
CLOSING_STATUSES,
|
||||
TICKET_STATUSES,
|
||||
type TicketStatus,
|
||||
} from "@/lib/tickets/ticketMeta";
|
||||
import { assignTicket, replyToTicket, updateTicketStatus } from "@/app/(frontend)/helpdesk/actions";
|
||||
import { CLOSING_STATUSES, TICKET_STATUSES, type TicketStatus } from "@/lib/tickets/ticketMeta";
|
||||
import { CalendarClockIcon, HistoryIcon, SendIcon, Settings2Icon } from "lucide-react";
|
||||
|
||||
interface AssigneeOption {
|
||||
|
|
@ -37,7 +39,7 @@ type UserRelation = number | User | null | undefined;
|
|||
type ControlKind = "status" | "assignee";
|
||||
|
||||
function relationId(relation: UserRelation): number | null {
|
||||
return typeof relation === "object" && relation ? relation.id : relation ?? null;
|
||||
return typeof relation === "object" && relation ? relation.id : (relation ?? null);
|
||||
}
|
||||
|
||||
function displayName(relation: UserRelation, fallback: string): string {
|
||||
|
|
@ -62,7 +64,9 @@ function isTicketStatus(value: string): value is TicketStatus {
|
|||
function MetaItem({ label, value }: { readonly label: string; readonly value: string }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{label}</span>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<span className="truncate text-sm">{value}</span>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -89,13 +93,17 @@ export function TicketDetail({
|
|||
const [cancelBusy, setCancelBusy] = useState(false);
|
||||
const cancelTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (cancelTimerRef.current) clearTimeout(cancelTimerRef.current);
|
||||
}, []);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (cancelTimerRef.current) clearTimeout(cancelTimerRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const reporterName = displayName(ticket.reporter, "Unknown reporter");
|
||||
const assigneeName = displayName(ticket.assignee, "Unassigned");
|
||||
const canCancel = relationId(ticket.reporter) === currentUserId && !CLOSING_STATUSES.includes(ticket.status);
|
||||
const canCancel =
|
||||
relationId(ticket.reporter) === currentUserId && !CLOSING_STATUSES.includes(ticket.status);
|
||||
const descriptionText = convertLexicalToPlaintext({ data: ticket.description });
|
||||
|
||||
async function applyControl(kind: ControlKind) {
|
||||
|
|
@ -170,58 +178,169 @@ export function TicketDetail({
|
|||
<section className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<h2 className="min-w-0 flex-1 text-xl font-semibold leading-tight">{ticket.title}</h2>
|
||||
<div className="flex flex-wrap items-center gap-1.5"><TicketCategoryBadge category={ticket.category} /><TicketBadges ticket={ticket} /></div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<TicketCategoryBadge category={ticket.category} />
|
||||
<TicketBadges ticket={ticket} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3 rounded-md border bg-muted/20 p-4 sm:grid-cols-4">
|
||||
<MetaItem label="Reporter" value={reporterName} /><MetaItem label="Assignee" value={assigneeName} />
|
||||
<MetaItem label="Created" value={formatDate(ticket.createdAt)} /><MetaItem label="Updated" value={formatDate(ticket.updatedAt)} />
|
||||
<MetaItem label="Reporter" value={reporterName} />
|
||||
<MetaItem label="Assignee" value={assigneeName} />
|
||||
<MetaItem label="Created" value={formatDate(ticket.createdAt)} />
|
||||
<MetaItem label="Updated" value={formatDate(ticket.updatedAt)} />
|
||||
<MetaItem label="Closed" value={formatDate(ticket.closedAt)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2"><CalendarClockIcon className="size-4 text-muted-foreground" /><h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Description</h3></div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarClockIcon className="size-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Description
|
||||
</h3>
|
||||
</div>
|
||||
<div className="rounded-md border bg-muted/20 p-4 text-sm leading-relaxed text-muted-foreground [&_p]:mb-2 [&_p:last-child]:mb-0 [&_strong]:text-foreground [&_em]:italic">
|
||||
{descriptionText ? <RichText data={ticket.description} /> : <p>No description provided.</p>}
|
||||
{descriptionText ? (
|
||||
<RichText data={ticket.description} />
|
||||
) : (
|
||||
<p>No description provided.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isStaff && (
|
||||
<Item variant="outline" className="bg-muted/20">
|
||||
<ItemHeader><div className="flex items-center gap-2"><Settings2Icon className="size-4 text-muted-foreground" /><ItemTitle>Staff controls</ItemTitle></div></ItemHeader>
|
||||
<ItemHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2Icon className="size-4 text-muted-foreground" />
|
||||
<ItemTitle>Staff controls</ItemTitle>
|
||||
</div>
|
||||
</ItemHeader>
|
||||
<ItemContent className="grid w-full grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5"><Label htmlFor="ticket-status">Status</Label><div className="flex gap-2">
|
||||
<Select value={status} onValueChange={(value) => { if (isTicketStatus(value)) setStatus(value); }} disabled={controlBusy !== null}>
|
||||
<SelectTrigger id="ticket-status" className="min-w-0 flex-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{TICKET_STATUSES.map((option) => <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" onClick={() => void applyControl("status")} disabled={controlBusy !== null}>{controlBusy === "status" ? "Applying..." : "Apply"}</Button>
|
||||
</div></div>
|
||||
<div className="flex flex-col gap-1.5"><Label htmlFor="ticket-assignee">Assignee</Label><div className="flex gap-2">
|
||||
<Select value={assignee} onValueChange={setAssignee} disabled={controlBusy !== null}>
|
||||
<SelectTrigger id="ticket-assignee" className="min-w-0 flex-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="unassigned">Unassigned</SelectItem>{assigneeOptions.map((option) => <SelectItem key={option.id} value={String(option.id)}>{option.label}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" onClick={() => void applyControl("assignee")} disabled={controlBusy !== null}>{controlBusy === "assignee" ? "Applying..." : "Apply"}</Button>
|
||||
</div></div>
|
||||
{controlError && <p role="alert" className="text-sm text-red-400 sm:col-span-2">{controlError}</p>}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="ticket-status">Status</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={(value) => {
|
||||
if (isTicketStatus(value)) setStatus(value);
|
||||
}}
|
||||
disabled={controlBusy !== null}
|
||||
>
|
||||
<SelectTrigger id="ticket-status" className="min-w-0 flex-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TICKET_STATUSES.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void applyControl("status")}
|
||||
disabled={controlBusy !== null}
|
||||
>
|
||||
{controlBusy === "status" ? "Applying..." : "Apply"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="ticket-assignee">Assignee</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={assignee}
|
||||
onValueChange={setAssignee}
|
||||
disabled={controlBusy !== null}
|
||||
>
|
||||
<SelectTrigger id="ticket-assignee" className="min-w-0 flex-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="unassigned">Unassigned</SelectItem>
|
||||
{assigneeOptions.map((option) => (
|
||||
<SelectItem key={option.id} value={String(option.id)}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void applyControl("assignee")}
|
||||
disabled={controlBusy !== null}
|
||||
>
|
||||
{controlBusy === "assignee" ? "Applying..." : "Apply"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{controlError && (
|
||||
<p role="alert" className="text-sm text-red-400 sm:col-span-2">
|
||||
{controlError}
|
||||
</p>
|
||||
)}
|
||||
</ItemContent>
|
||||
</Item>
|
||||
)}
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2"><HistoryIcon className="size-4 text-muted-foreground" /><h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Activity</h3><span className="text-xs text-muted-foreground">{ticket.activity?.length ?? 0} entries</span></div>
|
||||
<Item variant="outline" className="bg-muted/20"><ItemContent className="w-full"><TicketTimeline ticket={ticket} currentUserId={currentUserId} /></ItemContent></Item>
|
||||
<div className="flex items-center gap-2">
|
||||
<HistoryIcon className="size-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Activity
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{ticket.activity?.length ?? 0} entries
|
||||
</span>
|
||||
</div>
|
||||
<Item variant="outline" className="bg-muted/20">
|
||||
<ItemContent className="w-full">
|
||||
<TicketTimeline ticket={ticket} currentUserId={currentUserId} />
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</section>
|
||||
|
||||
<Item variant="outline">
|
||||
<ItemHeader><div className="flex items-center gap-2"><SendIcon className="size-4 text-muted-foreground" /><ItemTitle>Reply</ItemTitle></div><span className="text-xs text-muted-foreground">{isStaff ? "Reply as staff" : "Send reply"}</span></ItemHeader>
|
||||
<ItemHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<SendIcon className="size-4 text-muted-foreground" />
|
||||
<ItemTitle>Reply</ItemTitle>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{isStaff ? "Reply as staff" : "Send reply"}
|
||||
</span>
|
||||
</ItemHeader>
|
||||
<ItemContent className="w-full gap-3">
|
||||
<Textarea rows={4} value={reply} onChange={(event) => setReply(event.target.value)} placeholder={isStaff ? "Write an update for the reporter..." : "Add more information..."} disabled={replyBusy} />
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">{replyError && <p role="alert" className="text-sm text-red-400">{replyError}</p>}<Button className="ml-auto" onClick={() => void handleReply()} disabled={replyBusy}>{replyBusy ? "Sending..." : isStaff ? "Reply as staff" : "Send reply"}</Button></div>
|
||||
<Textarea
|
||||
rows={4}
|
||||
value={reply}
|
||||
onChange={(event) => setReply(event.target.value)}
|
||||
placeholder={
|
||||
isStaff ? "Write an update for the reporter..." : "Add more information..."
|
||||
}
|
||||
disabled={replyBusy}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
{replyError && (
|
||||
<p role="alert" className="text-sm text-red-400">
|
||||
{replyError}
|
||||
</p>
|
||||
)}
|
||||
<Button className="ml-auto" onClick={() => void handleReply()} disabled={replyBusy}>
|
||||
{replyBusy ? "Sending..." : isStaff ? "Reply as staff" : "Send reply"}
|
||||
</Button>
|
||||
</div>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
|
||||
{canCancel && <div className="flex justify-end"><Button variant="destructive" onClick={requestCancel} disabled={cancelBusy}>{cancelBusy ? "Canceling..." : confirmCancel ? "Confirm cancel?" : "Cancel ticket"}</Button></div>}
|
||||
{canCancel && (
|
||||
<div className="flex justify-end">
|
||||
<Button variant="destructive" onClick={requestCancel} disabled={cancelBusy}>
|
||||
{cancelBusy ? "Canceling..." : confirmCancel ? "Confirm cancel?" : "Cancel ticket"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ function resolveUser(relation: UserRelation): User | null {
|
|||
}
|
||||
|
||||
function userId(relation: UserRelation): number | null {
|
||||
return typeof relation === "object" && relation ? relation.id : relation ?? null;
|
||||
return typeof relation === "object" && relation ? relation.id : (relation ?? null);
|
||||
}
|
||||
|
||||
function displayName(relation: UserRelation, fallback: string): string {
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@ import type { Campaign } from "@/payload-types";
|
|||
import { cn } from "@/lib/utils";
|
||||
import { ClockIcon, CrosshairIcon, UsersIcon } from "lucide-react";
|
||||
|
||||
interface CampaignCardCampaign
|
||||
extends Pick<Campaign, "id" | "name" | "summary" | "status" | "startDate" | "endDate"> {
|
||||
interface CampaignCardCampaign extends Pick<
|
||||
Campaign,
|
||||
"id" | "name" | "summary" | "status" | "startDate" | "endDate"
|
||||
> {
|
||||
readonly coverImageUrl: string | null;
|
||||
readonly missionCount: number;
|
||||
readonly avgPlayers: number;
|
||||
|
|
|
|||
|
|
@ -76,9 +76,7 @@ export function FactionCard({ faction }: FactionCardProps) {
|
|||
? convertLexicalToPlaintext({ data: faction.description })
|
||||
: null;
|
||||
const truncatedDescription =
|
||||
description && description.length > 150
|
||||
? description.slice(0, 150) + "…"
|
||||
: description;
|
||||
description && description.length > 150 ? description.slice(0, 150) + "…" : description;
|
||||
|
||||
const campaignCount = faction.associatedCampaigns?.length ?? 0;
|
||||
const reputation = faction.reputation ?? 0;
|
||||
|
|
@ -105,9 +103,7 @@ export function FactionCard({ faction }: FactionCardProps) {
|
|||
<ItemContent>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
{truncatedDescription && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{truncatedDescription}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">{truncatedDescription}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{faction.playableFaction && (
|
||||
|
|
@ -134,9 +130,7 @@ export function FactionCard({ faction }: FactionCardProps) {
|
|||
</div>
|
||||
</div>
|
||||
</ItemContent>
|
||||
<ItemDescription>
|
||||
{truncatedDescription ?? "No description provided."}
|
||||
</ItemDescription>
|
||||
<ItemDescription>{truncatedDescription ?? "No description provided."}</ItemDescription>
|
||||
</Item>
|
||||
</Link>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -111,25 +111,19 @@ export function MissionAttendance({
|
|||
</div>
|
||||
)}
|
||||
{currentUserId !== null && attendanceClosed && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Attendance is closed for this mission.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Attendance is closed for this mission.</p>
|
||||
)}
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
{states.map((response) => {
|
||||
const meta = stateMeta[response];
|
||||
const Icon = meta.icon;
|
||||
const people = groups[response]
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const people = groups[response].slice().sort((a, b) => a.name.localeCompare(b.name));
|
||||
return (
|
||||
<div key={response} className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider">
|
||||
<Icon className={cn("size-3.5", meta.color)} />
|
||||
<span className={meta.color}>{meta.label}</span>
|
||||
<span className="font-mono text-muted-foreground">
|
||||
({people.length})
|
||||
</span>
|
||||
<span className="font-mono text-muted-foreground">({people.length})</span>
|
||||
</div>
|
||||
{people.length > 0 && (
|
||||
<ul className="flex flex-wrap gap-x-3 gap-y-0.5">
|
||||
|
|
|
|||
|
|
@ -36,9 +36,7 @@ export function MissionBriefingSection({ briefing }: MissionBriefingSectionProps
|
|||
</div>
|
||||
</ItemHeader>
|
||||
<ItemContent>
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No briefing available.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground italic">No briefing available.</p>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -29,9 +29,7 @@ export function MissionFactions({ factionsInvolved }: MissionFactionsProps) {
|
|||
</div>
|
||||
</ItemHeader>
|
||||
<ItemContent>
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No factions assigned.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground italic">No factions assigned.</p>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
);
|
||||
|
|
@ -47,15 +45,12 @@ export function MissionFactions({ factionsInvolved }: MissionFactionsProps) {
|
|||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{factionsInvolved.map((entry, index) => {
|
||||
const faction =
|
||||
typeof entry.faction === "object" ? entry.faction : null;
|
||||
const faction = typeof entry.faction === "object" ? entry.faction : null;
|
||||
|
||||
return (
|
||||
<Item key={index} variant="outline">
|
||||
<ItemHeader>
|
||||
<ItemTitle>
|
||||
{faction?.name ?? `Faction #${entry.faction}`}
|
||||
</ItemTitle>
|
||||
<ItemTitle>{faction?.name ?? `Faction #${entry.faction}`}</ItemTitle>
|
||||
<span
|
||||
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${threatLevelColors[entry.threatLevel] ?? "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
|
|
@ -64,9 +59,7 @@ export function MissionFactions({ factionsInvolved }: MissionFactionsProps) {
|
|||
</ItemHeader>
|
||||
{faction && (
|
||||
<ItemContent>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{faction.type}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{faction.type}</span>
|
||||
</ItemContent>
|
||||
)}
|
||||
</Item>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState } from "react";
|
||||
import type { Campaign } from "@/payload-types";
|
||||
import { MissionCard } from "@/components/frontend/intelligence/MissionCard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MissionFiltersProps {
|
||||
campaigns: Campaign[];
|
||||
|
|
@ -21,20 +19,9 @@ const operationTypes = [
|
|||
{ label: "External", value: "external" },
|
||||
];
|
||||
|
||||
const statuses = [
|
||||
"Concept",
|
||||
"Planning",
|
||||
"Ready",
|
||||
"Scheduled",
|
||||
"Active",
|
||||
"Completed",
|
||||
"Cancelled",
|
||||
];
|
||||
const statuses = ["Concept", "Planning", "Ready", "Scheduled", "Active", "Completed", "Cancelled"];
|
||||
|
||||
export function MissionFilters({
|
||||
campaigns,
|
||||
onFilterChange,
|
||||
}: MissionFiltersProps) {
|
||||
export function MissionFilters({ campaigns, onFilterChange }: MissionFiltersProps) {
|
||||
const [campaignId, setCampaignId] = useState<number | null>(null);
|
||||
const [operationType, setOperationType] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
|
|
|
|||
|
|
@ -23,12 +23,7 @@ const operationTypeColors: Record<string, string> = {
|
|||
external: "bg-purple-500/20 text-purple-400",
|
||||
};
|
||||
|
||||
export function MissionHeader({
|
||||
name,
|
||||
codeName,
|
||||
operationType,
|
||||
status,
|
||||
}: MissionHeaderProps) {
|
||||
export function MissionHeader({ name, codeName, operationType, status }: MissionHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -110,9 +110,7 @@ export function MissionLogistics({ logisticsAndSupport }: MissionLogisticsProps)
|
|||
<tbody>
|
||||
{assetsAvailable!.map((asset, index) => (
|
||||
<tr key={index} className="border-b border-border/50">
|
||||
<td className="px-4 py-2 font-medium">
|
||||
{asset.assetType}
|
||||
</td>
|
||||
<td className="px-4 py-2 font-medium">{asset.assetType}</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">
|
||||
{asset.assetCount ?? "—"}
|
||||
</td>
|
||||
|
|
@ -134,9 +132,7 @@ export function MissionLogistics({ logisticsAndSupport }: MissionLogisticsProps)
|
|||
</div>
|
||||
)}
|
||||
{!hasInsertion && !hasExtraction && !hasAssets && (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No logistics details provided.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground italic">No logistics details provided.</p>
|
||||
)}
|
||||
</ItemContent>
|
||||
</Item>
|
||||
|
|
|
|||
|
|
@ -35,9 +35,7 @@ export function MissionRoles({ roles, notes, maxPlayers }: MissionRolesProps) {
|
|||
</div>
|
||||
</ItemHeader>
|
||||
<ItemContent>
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No roles defined.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground italic">No roles defined.</p>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
);
|
||||
|
|
@ -51,8 +49,7 @@ export function MissionRoles({ roles, notes, maxPlayers }: MissionRolesProps) {
|
|||
Roles
|
||||
</h2>
|
||||
<span className="ml-auto text-xs font-mono text-muted-foreground">
|
||||
Max Players:{" "}
|
||||
<span className="text-foreground font-semibold">{maxPlayers}</span>
|
||||
Max Players: <span className="text-foreground font-semibold">{maxPlayers}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
|
|
@ -90,8 +87,7 @@ export function MissionRoles({ roles, notes, maxPlayers }: MissionRolesProps) {
|
|||
: "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">
|
||||
{typeof role.minimumRank === "object" &&
|
||||
role.minimumRank !== null
|
||||
{typeof role.minimumRank === "object" && role.minimumRank !== null
|
||||
? role.minimumRank.name
|
||||
: typeof role.minimumRank === "number"
|
||||
? `#${role.minimumRank}`
|
||||
|
|
@ -102,9 +98,7 @@ export function MissionRoles({ roles, notes, maxPlayers }: MissionRolesProps) {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{notes && (
|
||||
<p className="text-sm text-muted-foreground italic">{notes}</p>
|
||||
)}
|
||||
{notes && <p className="text-sm text-muted-foreground italic">{notes}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import type { Mission, Campaign, Map } from "@/payload-types";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Campaign, Mission } from "@/payload-types";
|
||||
import { MissionCard } from "@/components/frontend/intelligence/MissionCard";
|
||||
import { MissionFilters } from "@/components/frontend/intelligence/MissionFilters";
|
||||
|
||||
|
|
@ -29,10 +29,7 @@ export function MissionsList({ missions, campaigns }: MissionsListProps) {
|
|||
if (filters.operationType && m.operationType !== filters.operationType) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
filters.status &&
|
||||
m.ownershipAndStatus.status !== filters.status
|
||||
) {
|
||||
if (filters.status && m.ownershipAndStatus.status !== filters.status) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -81,9 +81,7 @@ export function AddLockerItemDialog({ assets }: AddLockerItemDialogProps) {
|
|||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Item to Locker</DialogTitle>
|
||||
<DialogDescription>
|
||||
Provision an asset into this member's locker.
|
||||
</DialogDescription>
|
||||
<DialogDescription>Provision an asset into this member's locker.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ import {
|
|||
getAssetTypeLabel,
|
||||
getAttachmentOptionsForItem,
|
||||
getSkinOptionsForItem,
|
||||
resolveAssetId,
|
||||
type LockerGridItem,
|
||||
resolveAssetId,
|
||||
} from "@/lib/locker";
|
||||
import { getAssetIcon } from "./LockerGrid";
|
||||
import {
|
||||
|
|
@ -61,7 +61,7 @@ export function EquipmentEditorDialog({
|
|||
}, [item, open]);
|
||||
|
||||
const resolvedItem = useMemo(
|
||||
() => (item ? items.find((i) => i.id === item.id) ?? item : null),
|
||||
() => (item ? (items.find((i) => i.id === item.id) ?? item) : null),
|
||||
[item, items],
|
||||
);
|
||||
|
||||
|
|
@ -167,18 +167,22 @@ export function EquipmentEditorDialog({
|
|||
<DialogHeader>
|
||||
<DialogTitle>Customize {displayName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Install attachments, apply a skin, and give your {getAssetTypeLabel(activeItem.asset.assetType).toLowerCase()} a name.
|
||||
Installing consumes the item from your locker grid; removing returns it.
|
||||
Install attachments, apply a skin, and give your{" "}
|
||||
{getAssetTypeLabel(activeItem.asset.assetType).toLowerCase()} a name. Installing
|
||||
consumes the item from your locker grid; removing returns it.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex items-center gap-3 rounded-md border p-3">
|
||||
<span className="text-amber-400">{getAssetIcon(activeItem.asset.assetType, "size-6")}</span>
|
||||
<span className="text-amber-400">
|
||||
{getAssetIcon(activeItem.asset.assetType, "size-6")}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">{activeItem.asset.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{getAssetTypeLabel(activeItem.asset.assetType)} · {activeItem.quantity.toLocaleString()}×
|
||||
{getAssetTypeLabel(activeItem.asset.assetType)} ·{" "}
|
||||
{activeItem.quantity.toLocaleString()}×
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -193,7 +197,10 @@ export function EquipmentEditorDialog({
|
|||
? [{ assetId: installed.id, quantity: 0, asset: installed }, ...options]
|
||||
: options;
|
||||
return (
|
||||
<div key={category.name} className="grid grid-cols-[100px_1fr] items-center gap-2">
|
||||
<div
|
||||
key={category.name}
|
||||
className="grid grid-cols-[100px_1fr] items-center gap-2"
|
||||
>
|
||||
<span className="text-xs text-muted-foreground">{category.label}</span>
|
||||
<Select
|
||||
value={installed ? String(installed.id) : "none"}
|
||||
|
|
@ -202,7 +209,11 @@ export function EquipmentEditorDialog({
|
|||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={categoryOptions.length === 0 ? "No compatible items" : "Choose an attachment"}
|
||||
placeholder={
|
||||
categoryOptions.length === 0
|
||||
? "No compatible items"
|
||||
: "Choose an attachment"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -219,8 +230,8 @@ export function EquipmentEditorDialog({
|
|||
);
|
||||
})}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Compatible with this weapon based on its catalog entry. Replacing an attachment returns
|
||||
the old one to your locker.
|
||||
Compatible with this weapon based on its catalog entry. Replacing an attachment
|
||||
returns the old one to your locker.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -236,14 +247,17 @@ export function EquipmentEditorDialog({
|
|||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={mergedSkinOptions.length === 0 ? "No compatible skins" : "Choose a skin"}
|
||||
placeholder={
|
||||
mergedSkinOptions.length === 0 ? "No compatible skins" : "Choose a skin"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
{mergedSkinOptions.map((o) => (
|
||||
<SelectItem key={o.assetId} value={String(o.assetId)}>
|
||||
{o.asset.name} · {o.quantity > 0 ? `${o.quantity.toLocaleString()}×` : "applied"}
|
||||
{o.asset.name} ·{" "}
|
||||
{o.quantity > 0 ? `${o.quantity.toLocaleString()}×` : "applied"}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -251,7 +265,8 @@ export function EquipmentEditorDialog({
|
|||
</div>
|
||||
{installedSkin && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Currently applied: {installedSkin.name}. Choosing another skin returns the old one to your locker.
|
||||
Currently applied: {installedSkin.name}. Choosing another skin returns the old one
|
||||
to your locker.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,12 @@ import { CheckCircle2Icon, PencilIcon, TrashIcon } from "lucide-react";
|
|||
import type { Asset, Loadout } from "@/payload-types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import { getAssetTypeLabel, LOADOUT_SLOTS, type LockerGridItem, type LoadoutSlots } from "@/lib/locker";
|
||||
import {
|
||||
getAssetTypeLabel,
|
||||
LOADOUT_SLOTS,
|
||||
type LoadoutSlots,
|
||||
type LockerGridItem,
|
||||
} from "@/lib/locker";
|
||||
|
||||
function resolveSlotValue(value: LoadoutSlots[keyof LoadoutSlots]): Asset | null {
|
||||
if (value == null) return null;
|
||||
|
|
@ -48,11 +53,7 @@ export function LoadoutCard({ loadout, ownedItems, onEdit, onDelete, onEquip }:
|
|||
<span className="text-xs text-muted-foreground">{loadout.description}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant={loadout.equipped ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={onEquip}
|
||||
>
|
||||
<Button variant={loadout.equipped ? "default" : "outline"} size="sm" onClick={onEquip}>
|
||||
{loadout.equipped ? "Unequip" : "Equip"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onEdit}>
|
||||
|
|
@ -80,7 +81,9 @@ export function LoadoutCard({ loadout, ownedItems, onEdit, onDelete, onEquip }:
|
|||
>
|
||||
<span className="text-muted-foreground">{slot.label}:</span>
|
||||
<span className="font-medium">{slotDisplayName(asset, ownedItems)}</span>
|
||||
<span className="text-muted-foreground">({getAssetTypeLabel(asset.assetType)})</span>
|
||||
<span className="text-muted-foreground">
|
||||
({getAssetTypeLabel(asset.assetType)})
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -22,16 +22,8 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
LOADOUT_SLOTS,
|
||||
type LockerGridItem,
|
||||
type LoadoutSlots,
|
||||
} from "@/lib/locker";
|
||||
import {
|
||||
createLoadout,
|
||||
setLoadoutSlot,
|
||||
updateLoadout,
|
||||
} from "@/app/(frontend)/locker/actions";
|
||||
import { LOADOUT_SLOTS, type LoadoutSlots, type LockerGridItem } from "@/lib/locker";
|
||||
import { createLoadout, setLoadoutSlot, updateLoadout } from "@/app/(frontend)/locker/actions";
|
||||
|
||||
interface LoadoutEditorProps {
|
||||
mode: "create" | "edit";
|
||||
|
|
|
|||
|
|
@ -3,18 +3,19 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
type DragMoveEvent,
|
||||
DragOverlay,
|
||||
type DragStartEvent,
|
||||
PointerSensor,
|
||||
useDraggable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragStartEvent,
|
||||
type DragMoveEvent,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
Backpack,
|
||||
Crosshair,
|
||||
FlaskConical,
|
||||
Gem,
|
||||
Glasses,
|
||||
|
|
@ -27,23 +28,22 @@ import {
|
|||
Palette,
|
||||
Shield,
|
||||
Shirt,
|
||||
Tag,
|
||||
Target,
|
||||
Wrench,
|
||||
Crosshair,
|
||||
Tag,
|
||||
} from "lucide-react";
|
||||
import type { Asset } from "@/payload-types";
|
||||
import {
|
||||
ATTACHMENT_CATEGORIES,
|
||||
findLockerMergeTarget,
|
||||
getAssetTypeColor,
|
||||
getLockerItemFootprint,
|
||||
getLockerGridOccupancy,
|
||||
getLockerItemFootprint,
|
||||
isAttachmentCompatible,
|
||||
isLockerPlacementValid,
|
||||
resolveAssetId,
|
||||
type LockerGridItem,
|
||||
type LockerItemEquipment,
|
||||
resolveAssetId,
|
||||
} from "@/lib/locker";
|
||||
import { GridCell } from "@/components/frontend/storage/GridCell";
|
||||
import type { ActionResult } from "@/app/(frontend)/locker/actions";
|
||||
|
|
@ -99,7 +99,9 @@ function assetThumbnailUrl(asset: Asset): string | null {
|
|||
|
||||
function itemContainsCell(item: LockerGridItem, x: number, y: number): boolean {
|
||||
const fp = getLockerItemFootprint(item.asset, item.rotated);
|
||||
return x >= item.gridX && x < item.gridX + fp.width && y >= item.gridY && y < item.gridY + fp.height;
|
||||
return (
|
||||
x >= item.gridX && x < item.gridX + fp.width && y >= item.gridY && y < item.gridY + fp.height
|
||||
);
|
||||
}
|
||||
|
||||
export function LockerItemFace({
|
||||
|
|
@ -159,7 +161,9 @@ export function LockerItemFace({
|
|||
{thumb ? (
|
||||
<img src={thumb} alt={asset.name} className="size-6 object-contain opacity-80" />
|
||||
) : (
|
||||
<span className="text-muted-foreground/70">{getAssetIcon(asset.assetType, "size-5")}</span>
|
||||
<span className="text-muted-foreground/70">
|
||||
{getAssetIcon(asset.assetType, "size-5")}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] leading-tight font-medium text-center text-foreground/90 line-clamp-2 px-0.5">
|
||||
{displayName}
|
||||
|
|
@ -467,11 +471,11 @@ export function LockerGrid({
|
|||
}
|
||||
|
||||
if (onAttachItem) {
|
||||
const isAttachment = ATTACHMENT_CATEGORIES.some((c) => c.assetType === item.asset.assetType);
|
||||
const isAttachment = ATTACHMENT_CATEGORIES.some(
|
||||
(c) => c.assetType === item.asset.assetType,
|
||||
);
|
||||
const targetItem =
|
||||
items.find(
|
||||
(it) => it.id !== item.id && itemContainsCell(it, targetX, targetY),
|
||||
) ?? null;
|
||||
items.find((it) => it.id !== item.id && itemContainsCell(it, targetX, targetY)) ?? null;
|
||||
if (
|
||||
isAttachment &&
|
||||
targetItem?.asset.assetType === "weapon" &&
|
||||
|
|
@ -508,7 +512,16 @@ export function LockerGrid({
|
|||
await onMoveItem(item.id, targetX, targetY, pendingRotated);
|
||||
}
|
||||
},
|
||||
[items, gridWidth, gridHeight, onMoveItem, onMergeStacks, onAttachItem, cellSize, pendingRotated],
|
||||
[
|
||||
items,
|
||||
gridWidth,
|
||||
gridHeight,
|
||||
onMoveItem,
|
||||
onMergeStacks,
|
||||
onAttachItem,
|
||||
cellSize,
|
||||
pendingRotated,
|
||||
],
|
||||
);
|
||||
|
||||
const handleBackgroundClick = useCallback(() => {
|
||||
|
|
@ -516,7 +529,12 @@ export function LockerGrid({
|
|||
}, [onSelectItem]);
|
||||
|
||||
return (
|
||||
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragMove={handleDragMove} onDragEnd={handleDragEnd}>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragMove={handleDragMove}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div ref={containerRef} className="rounded-lg border bg-muted/20 p-2">
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -2,22 +2,22 @@
|
|||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ShuffleIcon,
|
||||
RotateCwIcon,
|
||||
TrashIcon,
|
||||
ScissorsIcon,
|
||||
LayoutGridIcon,
|
||||
ListIcon,
|
||||
RotateCwIcon,
|
||||
ScissorsIcon,
|
||||
ShuffleIcon,
|
||||
SlidersHorizontalIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
findLockerEmptySpot,
|
||||
getAssetTypeLabel,
|
||||
getLockerItemFootprint,
|
||||
getLockerGridOccupancy,
|
||||
getLockerItemFootprint,
|
||||
type LockerGridItem,
|
||||
lockerIsRotatable,
|
||||
lockerIsStackable,
|
||||
type LockerGridItem,
|
||||
} from "@/lib/locker";
|
||||
|
||||
interface LockerGridToolbarProps {
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ import { LockerSearch } from "./LockerSearch";
|
|||
import { getAssetTypeLabel, type LockerGridItem } from "@/lib/locker";
|
||||
import type { LockerSearchEntry } from "@/lib/locker/search";
|
||||
import {
|
||||
autoArrangeLocker,
|
||||
attachItemFromGrid,
|
||||
autoArrangeLocker,
|
||||
mergeLockerStacks,
|
||||
moveLockerItem,
|
||||
removeLockerItem,
|
||||
|
|
|
|||
|
|
@ -14,11 +14,10 @@ import {
|
|||
} from "lucide-react";
|
||||
import type { Asset, Loadout } from "@/payload-types";
|
||||
import {
|
||||
WARDROBE_SLOT_LAYOUT,
|
||||
getLoadoutSlot,
|
||||
type WardrobeSlotIcon,
|
||||
type LoadoutSlots,
|
||||
type LockerGridItem,
|
||||
WARDROBE_SLOT_LAYOUT,
|
||||
type WardrobeSlotIcon,
|
||||
} from "@/lib/locker";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getAssetIcon } from "./LockerGrid";
|
||||
|
|
@ -121,9 +120,7 @@ export function Wardrobe({
|
|||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-semibold">{layout.label}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{asset ? asset.name : "Empty"}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{asset ? asset.name : "Empty"}</span>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -15,12 +15,12 @@ import {
|
|||
import { Wardrobe } from "./Wardrobe";
|
||||
import { WardrobeSlotPicker } from "./WardrobeSlotPicker";
|
||||
import {
|
||||
WARDROBE_SLOT_LAYOUT,
|
||||
getAssetTypeLabel,
|
||||
getWardrobeSlot,
|
||||
getLoadoutSlot,
|
||||
getWardrobeSlot,
|
||||
type LoadoutSlots,
|
||||
type LockerGridItem,
|
||||
WARDROBE_SLOT_LAYOUT,
|
||||
} from "@/lib/locker";
|
||||
import { equipLoadout, setLoadoutSlot } from "@/app/(frontend)/locker/actions";
|
||||
import { getAssetIcon } from "./LockerGrid";
|
||||
|
|
@ -75,7 +75,7 @@ export function WardrobePanel({ userName, loadouts, equippedLoadout, items }: Wa
|
|||
|
||||
const pickerLayout = pickerSlot ? getWardrobeSlot(pickerSlot) : null;
|
||||
const pickerConfig = pickerSlot ? getLoadoutSlot(pickerSlot) : null;
|
||||
const pickerValue = pickerSlot ? slots[pickerSlot as keyof LoadoutSlots] ?? null : null;
|
||||
const pickerValue = pickerSlot ? (slots[pickerSlot as keyof LoadoutSlots] ?? null) : null;
|
||||
|
||||
const filled = equippedLoadout
|
||||
? WARDROBE_SLOT_LAYOUT.filter((layout) => slots[layout.name] != null)
|
||||
|
|
@ -89,9 +89,7 @@ export function WardrobePanel({ userName, loadouts, equippedLoadout, items }: Wa
|
|||
</ItemHeader>
|
||||
<ItemContent>
|
||||
<Wardrobe
|
||||
loadout={
|
||||
equippedLoadout ?? ({ name: "Empty", slots: {} } as unknown as Loadout)
|
||||
}
|
||||
loadout={equippedLoadout ?? ({ name: "Empty", slots: {} } as unknown as Loadout)}
|
||||
items={items}
|
||||
onSlotClick={(name) => setPickerSlot(name)}
|
||||
/>
|
||||
|
|
@ -124,7 +122,9 @@ export function WardrobePanel({ userName, loadouts, equippedLoadout, items }: Wa
|
|||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue
|
||||
placeholder={equippedLoadout ? equippedLoadout.name : "Select a loadout to equip"}
|
||||
placeholder={
|
||||
equippedLoadout ? equippedLoadout.name : "Select a loadout to equip"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -149,7 +149,8 @@ export function WardrobePanel({ userName, loadouts, equippedLoadout, items }: Wa
|
|||
|
||||
{!equippedLoadout && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose a loadout above — it becomes the active loadout that the wardrobe reflects.
|
||||
Choose a loadout above — it becomes the active loadout that the wardrobe
|
||||
reflects.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -178,7 +179,9 @@ export function WardrobePanel({ userName, loadouts, equippedLoadout, items }: Wa
|
|||
onClick={() => setPickerSlot(layout.name)}
|
||||
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-left text-sm hover:bg-muted transition-colors"
|
||||
>
|
||||
<span className="text-amber-400">{getAssetIcon(asset.assetType, "size-4")}</span>
|
||||
<span className="text-amber-400">
|
||||
{getAssetIcon(asset.assetType, "size-4")}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-xs text-muted-foreground">{layout.label}</span>
|
||||
<span className="block font-medium truncate">
|
||||
|
|
|
|||
|
|
@ -103,9 +103,7 @@ export function WardrobeSlotPicker({
|
|||
onClick={() => handlePick(entry.assetId)}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md border px-3 py-2 text-left text-sm transition-colors",
|
||||
selected
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border hover:bg-muted",
|
||||
selected ? "border-primary bg-primary/10" : "border-border hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground">{getAssetIcon(entry.asset.assetType)}</span>
|
||||
|
|
@ -122,11 +120,7 @@ export function WardrobeSlotPicker({
|
|||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy || pending}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<Button variant="outline" disabled={busy || pending} onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
{currentId != null && (
|
||||
|
|
|
|||
|
|
@ -86,15 +86,12 @@ const rarityLabels: Record<string, string> = {
|
|||
};
|
||||
|
||||
export function ResourceCard({ resource }: ResourceCardProps) {
|
||||
const icon =
|
||||
resource.icon && typeof resource.icon === "object" ? resource.icon : null;
|
||||
const icon = resource.icon && typeof resource.icon === "object" ? resource.icon : null;
|
||||
const description = resource.description
|
||||
? convertLexicalToPlaintext({ data: resource.description })
|
||||
: null;
|
||||
const truncatedDescription =
|
||||
description && description.length > 150
|
||||
? description.slice(0, 150) + "…"
|
||||
: description;
|
||||
description && description.length > 150 ? description.slice(0, 150) + "…" : description;
|
||||
|
||||
return (
|
||||
<Link href={`/logistics/resources/${resource.id}`}>
|
||||
|
|
@ -106,11 +103,7 @@ export function ResourceCard({ resource }: ResourceCardProps) {
|
|||
<div className="flex items-center gap-2">
|
||||
{icon ? (
|
||||
<ItemMedia variant="image">
|
||||
<img
|
||||
src={icon.url}
|
||||
alt={resource.name}
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
<img src={icon.url} alt={resource.name} className="size-full object-cover" />
|
||||
</ItemMedia>
|
||||
) : resource.color ? (
|
||||
<ItemMedia variant="icon">
|
||||
|
|
@ -123,9 +116,7 @@ export function ResourceCard({ resource }: ResourceCardProps) {
|
|||
<ItemMedia variant="icon">{typeIcons[resource.type]}</ItemMedia>
|
||||
)}
|
||||
<ItemTitle className="flex flex-col">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{resource.codeName}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">{resource.codeName}</span>
|
||||
<span>{resource.name}</span>
|
||||
</ItemTitle>
|
||||
</div>
|
||||
|
|
@ -145,9 +136,7 @@ export function ResourceCard({ resource }: ResourceCardProps) {
|
|||
<ItemContent>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
{truncatedDescription && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{truncatedDescription}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">{truncatedDescription}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1 font-medium text-foreground">
|
||||
|
|
|
|||
|
|
@ -26,8 +26,7 @@ export function ShipmentActions({ shipment }: ShipmentActionsProps) {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCancelDialog, setShowCancelDialog] = useState(false);
|
||||
|
||||
const isActive =
|
||||
shipment.status === "dispatched" || shipment.status === "in_transit";
|
||||
const isActive = shipment.status === "dispatched" || shipment.status === "in_transit";
|
||||
|
||||
if (!isActive) return null;
|
||||
|
||||
|
|
@ -49,10 +48,7 @@ export function ShipmentActions({ shipment }: ShipmentActionsProps) {
|
|||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await toggleAutoReturn(
|
||||
shipment.id,
|
||||
!shipment.autoReturn,
|
||||
);
|
||||
const result = await toggleAutoReturn(shipment.id, !shipment.autoReturn);
|
||||
if (result.success) {
|
||||
router.refresh();
|
||||
} else {
|
||||
|
|
@ -63,15 +59,8 @@ export function ShipmentActions({ shipment }: ShipmentActionsProps) {
|
|||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{error && (
|
||||
<span className="text-xs text-red-400">{error}</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleToggleAutoReturn}
|
||||
disabled={loading}
|
||||
>
|
||||
{error && <span className="text-xs text-red-400">{error}</span>}
|
||||
<Button variant="outline" size="sm" onClick={handleToggleAutoReturn} disabled={loading}>
|
||||
<RotateCcwIcon className="size-3.5 mr-1.5" />
|
||||
{shipment.autoReturn ? "Disable" : "Enable"} Auto-Return
|
||||
</Button>
|
||||
|
|
@ -86,23 +75,15 @@ export function ShipmentActions({ shipment }: ShipmentActionsProps) {
|
|||
<DialogHeader>
|
||||
<DialogTitle>Cancel Shipment</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to cancel this shipment? All cargo will be
|
||||
returned to the origin structure and the vehicle will be parked
|
||||
at the origin.
|
||||
Are you sure you want to cancel this shipment? All cargo will be returned to the
|
||||
origin structure and the vehicle will be parked at the origin.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowCancelDialog(false)}
|
||||
>
|
||||
<Button variant="outline" onClick={() => setShowCancelDialog(false)}>
|
||||
Keep Shipment
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
<Button variant="destructive" onClick={handleCancel} disabled={loading}>
|
||||
{loading ? "Cancelling..." : "Cancel Shipment"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import type { Shipment, GameStructure, GameVehicle, Vehicle } from "@/payload-types";
|
||||
import type { GameStructure, GameVehicle, Shipment, Vehicle } from "@/payload-types";
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
|
|
@ -38,27 +38,19 @@ export function ShipmentCard({ shipment }: ShipmentCardProps) {
|
|||
? (shipment.destination as GameStructure)
|
||||
: null;
|
||||
const vehicle =
|
||||
typeof shipment.transportVehicle === "object" &&
|
||||
shipment.transportVehicle !== null
|
||||
typeof shipment.transportVehicle === "object" && shipment.transportVehicle !== null
|
||||
? (shipment.transportVehicle as GameVehicle & { type: Vehicle })
|
||||
: null;
|
||||
|
||||
const vehicleType =
|
||||
vehicle && typeof vehicle.type === "object" ? vehicle.type : null;
|
||||
const vehicleType = vehicle && typeof vehicle.type === "object" ? vehicle.type : null;
|
||||
|
||||
const dispatchedAt = shipment.dispatchedAt
|
||||
? new Date(shipment.dispatchedAt)
|
||||
: null;
|
||||
const estimatedArrival = shipment.estimatedArrival
|
||||
? new Date(shipment.estimatedArrival)
|
||||
: null;
|
||||
const dispatchedAt = shipment.dispatchedAt ? new Date(shipment.dispatchedAt) : null;
|
||||
const estimatedArrival = shipment.estimatedArrival ? new Date(shipment.estimatedArrival) : null;
|
||||
|
||||
const isActive =
|
||||
shipment.status === "dispatched" || shipment.status === "in_transit";
|
||||
const isActive = shipment.status === "dispatched" || shipment.status === "in_transit";
|
||||
|
||||
const cargoCount = shipment.cargo?.length ?? 0;
|
||||
const totalItems =
|
||||
shipment.cargo?.reduce((sum, c) => sum + (c.amount ?? 0), 0) ?? 0;
|
||||
const totalItems = shipment.cargo?.reduce((sum, c) => sum + (c.amount ?? 0), 0) ?? 0;
|
||||
|
||||
return (
|
||||
<Link href={`/logistics/shipments/${shipment.id}`}>
|
||||
|
|
@ -83,16 +75,13 @@ export function ShipmentCard({ shipment }: ShipmentCardProps) {
|
|||
>
|
||||
{shipment.status === "in_transit"
|
||||
? "In Transit"
|
||||
: shipment.status.charAt(0).toUpperCase() +
|
||||
shipment.status.slice(1)}
|
||||
: shipment.status.charAt(0).toUpperCase() + shipment.status.slice(1)}
|
||||
</span>
|
||||
</ItemHeader>
|
||||
<ItemContent>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="truncate">
|
||||
{origin?.name ?? `Structure #${shipment.origin}`}
|
||||
</span>
|
||||
<span className="truncate">{origin?.name ?? `Structure #${shipment.origin}`}</span>
|
||||
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
|
||||
<span className="truncate">
|
||||
{destination?.name ?? `Structure #${shipment.destination}`}
|
||||
|
|
@ -108,8 +97,7 @@ export function ShipmentCard({ shipment }: ShipmentCardProps) {
|
|||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{cargoCount} item type{cargoCount !== 1 ? "s" : ""} ({totalItems}{" "}
|
||||
total)
|
||||
{cargoCount} item type{cargoCount !== 1 ? "s" : ""} ({totalItems} total)
|
||||
</span>
|
||||
<span className="font-mono">
|
||||
{shipment.distance?.toFixed(1)} units · {shipment.fuelCost} fuel
|
||||
|
|
@ -122,7 +110,7 @@ export function ShipmentCard({ shipment }: ShipmentCardProps) {
|
|||
? `Completed ${shipment.completedAt ? new Date(shipment.completedAt).toLocaleDateString() : ""}`
|
||||
: shipment.status === "cancelled"
|
||||
? "Cancelled"
|
||||
: shipment.failureReason ?? (isActive ? "In transit" : shipment.status)}
|
||||
: (shipment.failureReason ?? (isActive ? "In transit" : shipment.status))}
|
||||
</ItemDescription>
|
||||
<span className="text-xs text-muted-foreground group-hover:text-primary transition-colors flex items-center gap-1">
|
||||
View details <ChevronRightIcon className="size-3" />
|
||||
|
|
|
|||
|
|
@ -1,16 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface ShipmentProgressBarProps {
|
||||
dispatchedAt: string;
|
||||
estimatedArrival: string;
|
||||
}
|
||||
|
||||
export function ShipmentProgressBar({
|
||||
dispatchedAt,
|
||||
estimatedArrival,
|
||||
}: ShipmentProgressBarProps) {
|
||||
export function ShipmentProgressBar({ dispatchedAt, estimatedArrival }: ShipmentProgressBarProps) {
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [countdown, setCountdown] = useState("");
|
||||
|
||||
|
|
@ -52,9 +49,7 @@ export function ShipmentProgressBar({
|
|||
style={{ width: `${Math.min(progress, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{Math.floor(progress)}%
|
||||
</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">{Math.floor(progress)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="font-mono">{countdown} remaining</span>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowRightIcon, ChevronDownIcon, ChevronUpIcon, TruckIcon } from "lucide-react";
|
||||
import type { Shipment, GameStructure, GameVehicle, Vehicle } from "@/payload-types";
|
||||
import type { GameStructure, GameVehicle, Shipment, Vehicle } from "@/payload-types";
|
||||
import { ShipmentProgressBar } from "@/components/frontend/logistics/ShipmentProgressBar";
|
||||
import { useGameTick } from "@/hooks/useGameTick";
|
||||
|
||||
|
|
@ -81,9 +81,7 @@ export function ShipmentToasts() {
|
|||
<div className="flex items-center gap-2">
|
||||
<TruckIcon className="size-4 text-primary" />
|
||||
<span className="text-sm font-semibold">Active Shipments</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{count}
|
||||
</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">{count}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
|
|
@ -109,17 +107,14 @@ export function ShipmentToasts() {
|
|||
? (shipment.origin as GameStructure)
|
||||
: null;
|
||||
const destination =
|
||||
typeof shipment.destination === "object" &&
|
||||
shipment.destination !== null
|
||||
typeof shipment.destination === "object" && shipment.destination !== null
|
||||
? (shipment.destination as GameStructure)
|
||||
: null;
|
||||
const vehicle =
|
||||
typeof shipment.transportVehicle === "object" &&
|
||||
shipment.transportVehicle !== null
|
||||
typeof shipment.transportVehicle === "object" && shipment.transportVehicle !== null
|
||||
? (shipment.transportVehicle as GameVehicle & { type: Vehicle })
|
||||
: null;
|
||||
const vehicleType =
|
||||
vehicle && typeof vehicle.type === "object" ? vehicle.type : null;
|
||||
const vehicleType = vehicle && typeof vehicle.type === "object" ? vehicle.type : null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
|
@ -149,9 +144,7 @@ export function ShipmentToasts() {
|
|||
</span>
|
||||
</div>
|
||||
{vehicleType && (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground/80">
|
||||
{vehicleType.name}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground/80">{vehicleType.name}</p>
|
||||
)}
|
||||
{shipment.dispatchedAt && shipment.estimatedArrival && (
|
||||
<div className="mt-2">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Shipment } from "@/payload-types";
|
||||
import { ShipmentCard } from "@/components/frontend/logistics/ShipmentCard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
|
|||
|
|
@ -10,12 +10,7 @@ import {
|
|||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
HeartIcon,
|
||||
TimerIcon,
|
||||
TruckIcon,
|
||||
} from "lucide-react";
|
||||
import { ChevronRightIcon, HeartIcon, TimerIcon, TruckIcon } from "lucide-react";
|
||||
|
||||
interface VehicleCardProps {
|
||||
vehicle: {
|
||||
|
|
@ -67,15 +62,12 @@ const categoryLabels: Record<string, string> = {
|
|||
};
|
||||
|
||||
export function VehicleCard({ vehicle }: VehicleCardProps) {
|
||||
const image =
|
||||
vehicle.image && typeof vehicle.image === "object" ? vehicle.image : null;
|
||||
const image = vehicle.image && typeof vehicle.image === "object" ? vehicle.image : null;
|
||||
const description = vehicle.description
|
||||
? convertLexicalToPlaintext({ data: vehicle.description })
|
||||
: null;
|
||||
const truncatedDescription =
|
||||
description && description.length > 150
|
||||
? description.slice(0, 150) + "…"
|
||||
: description;
|
||||
description && description.length > 150 ? description.slice(0, 150) + "…" : description;
|
||||
|
||||
const materialCount = vehicle.construction?.materials?.length ?? 0;
|
||||
const constructionTime = vehicle.construction?.constructionTime;
|
||||
|
|
@ -92,11 +84,7 @@ export function VehicleCard({ vehicle }: VehicleCardProps) {
|
|||
<div className="flex items-center gap-2">
|
||||
{image ? (
|
||||
<ItemMedia variant="image">
|
||||
<img
|
||||
src={image.url}
|
||||
alt={vehicle.name}
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
<img src={image.url} alt={vehicle.name} className="size-full object-cover" />
|
||||
</ItemMedia>
|
||||
) : (
|
||||
<ItemMedia variant="icon">
|
||||
|
|
@ -105,9 +93,7 @@ export function VehicleCard({ vehicle }: VehicleCardProps) {
|
|||
)}
|
||||
<ItemTitle className="flex flex-col">
|
||||
{vehicle.classname && (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{vehicle.classname}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">{vehicle.classname}</span>
|
||||
)}
|
||||
<span>{vehicle.name}</span>
|
||||
</ItemTitle>
|
||||
|
|
@ -126,9 +112,7 @@ export function VehicleCard({ vehicle }: VehicleCardProps) {
|
|||
<ItemContent>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
{truncatedDescription && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{truncatedDescription}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">{truncatedDescription}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
|
||||
{materialCount > 0 && (
|
||||
|
|
@ -136,8 +120,7 @@ export function VehicleCard({ vehicle }: VehicleCardProps) {
|
|||
{materialCount} material{materialCount !== 1 ? "s" : ""}
|
||||
{constructionTime != null && (
|
||||
<>
|
||||
,{" "}
|
||||
<TimerIcon className="size-3" />
|
||||
, <TimerIcon className="size-3" />
|
||||
{constructionTime} tick{constructionTime !== 1 ? "s" : ""}
|
||||
</>
|
||||
)}
|
||||
|
|
@ -158,9 +141,7 @@ export function VehicleCard({ vehicle }: VehicleCardProps) {
|
|||
</div>
|
||||
</div>
|
||||
</ItemContent>
|
||||
<ItemDescription>
|
||||
{truncatedDescription ?? vehicle.classname ?? "Vehicle"}
|
||||
</ItemDescription>
|
||||
<ItemDescription>{truncatedDescription ?? vehicle.classname ?? "Vehicle"}</ItemDescription>
|
||||
<span className="text-xs text-muted-foreground group-hover:text-primary transition-colors flex items-center gap-1">
|
||||
View details <ChevronRightIcon className="size-3" />
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface VehicleFiltersProps {
|
||||
categories: string[];
|
||||
|
|
|
|||
|
|
@ -41,11 +41,7 @@ export function ChatBubbleList({
|
|||
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{bubble.from === "buyer" ? buyerLabel : sellerLabel}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
bubble.from === "seller" ? "text-amber-100/90" : "text-blue-100/90",
|
||||
)}
|
||||
>
|
||||
<span className={cn(bubble.from === "seller" ? "text-amber-100/90" : "text-blue-100/90")}>
|
||||
{bubble.text}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -74,9 +74,10 @@ export function CreateListingDialog({ locker, currencyLabel }: CreateListingDial
|
|||
quantity,
|
||||
tradeable: asset.tradeable === true,
|
||||
isLive: asset.isLive === true,
|
||||
basePrice: typeof asset.marketTrading?.perceivedValue?.baseBuyPrice === "number"
|
||||
? asset.marketTrading.perceivedValue.baseBuyPrice
|
||||
: null,
|
||||
basePrice:
|
||||
typeof asset.marketTrading?.perceivedValue?.baseBuyPrice === "number"
|
||||
? asset.marketTrading.perceivedValue.baseBuyPrice
|
||||
: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -164,14 +165,20 @@ export function CreateListingDialog({ locker, currencyLabel }: CreateListingDial
|
|||
<DialogHeader>
|
||||
<DialogTitle>Sell Item</DialogTitle>
|
||||
<DialogDescription>
|
||||
List items from your locker on the market. Listings last 30 days and stock is
|
||||
returned if they expire.
|
||||
List items from your locker on the market. Listings last 30 days and stock is returned
|
||||
if they expire.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Item</Label>
|
||||
<Select value={assetId} onValueChange={(v) => { setAssetId(v); setQuantity("1"); }}>
|
||||
<Select
|
||||
value={assetId}
|
||||
onValueChange={(v) => {
|
||||
setAssetId(v);
|
||||
setQuantity("1");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an item from your locker" />
|
||||
</SelectTrigger>
|
||||
|
|
@ -216,7 +223,9 @@ export function CreateListingDialog({ locker, currencyLabel }: CreateListingDial
|
|||
step="any"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
placeholder={selected.basePrice != null ? `Suggested ~${selected.basePrice}` : "0"}
|
||||
placeholder={
|
||||
selected.basePrice != null ? `Suggested ~${selected.basePrice}` : "0"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -255,7 +264,9 @@ export function CreateListingDialog({ locker, currencyLabel }: CreateListingDial
|
|||
|
||||
{selected && selected.basePrice != null && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="mr-1">Tip</Badge>
|
||||
<Badge variant="outline" className="mr-1">
|
||||
Tip
|
||||
</Badge>
|
||||
Resting valuation is {selected.basePrice.toLocaleString()} per unit.
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,16 @@ import type { Asset, MarketListing } from "@/payload-types";
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Item, ItemActions, ItemContent, ItemDescription, ItemFooter, ItemHeader, ItemMedia, ItemTitle } from "@/components/ui/item";
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemFooter,
|
||||
ItemHeader,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import { getAssetTypeLabel } from "@/lib/locker";
|
||||
import { getRarityLabel, isNegotiable } from "@/lib/market";
|
||||
import { formatAmount } from "@/lib/banking/format";
|
||||
|
|
@ -42,7 +51,12 @@ function sellerName(listing: MarketListing): string {
|
|||
return npcLabel(listing);
|
||||
}
|
||||
|
||||
export function ListingCard({ listing, currentUserId, currencyLabel, canManage }: ListingCardProps) {
|
||||
export function ListingCard({
|
||||
listing,
|
||||
currentUserId,
|
||||
currencyLabel,
|
||||
canManage,
|
||||
}: ListingCardProps) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState<"buy" | "cancel" | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -51,7 +65,8 @@ export function ListingCard({ listing, currentUserId, currencyLabel, canManage }
|
|||
const asset = listing.asset as Asset;
|
||||
const assetName = typeof asset === "object" && asset ? asset.name : `Asset #${listing.asset}`;
|
||||
const rarity = typeof asset === "object" && asset ? getRarityLabel(asset) : null;
|
||||
const assetType = typeof asset === "object" && asset ? getAssetTypeLabel(asset.assetType) : "Item";
|
||||
const assetType =
|
||||
typeof asset === "object" && asset ? getAssetTypeLabel(asset.assetType) : "Item";
|
||||
const stock = listing.quantity ?? 1;
|
||||
const negotiable = isNegotiable(listing);
|
||||
const isOwn = (() => {
|
||||
|
|
@ -170,7 +185,11 @@ export function ListingCard({ listing, currentUserId, currencyLabel, canManage }
|
|||
{busy === "buy" ? "Buying..." : stock > 1 ? `Buy ${selectedQty}` : "Buy"}
|
||||
</Button>
|
||||
{negotiable && !isOwn && (
|
||||
<MakeOfferDialog listing={listing} currentUserId={currentUserId} currencyLabel={currencyLabel} />
|
||||
<MakeOfferDialog
|
||||
listing={listing}
|
||||
currentUserId={currentUserId}
|
||||
currencyLabel={currencyLabel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{canCancel && (
|
||||
|
|
|
|||
|
|
@ -129,7 +129,10 @@ export function MakeOfferDialog({ listing, currentUserId, currencyLabel }: MakeO
|
|||
}
|
||||
}
|
||||
|
||||
async function run(action: () => Promise<{ success: boolean; error?: string; data?: unknown }>, key: string) {
|
||||
async function run(
|
||||
action: () => Promise<{ success: boolean; error?: string; data?: unknown }>,
|
||||
key: string,
|
||||
) {
|
||||
setBusy(key);
|
||||
setError(null);
|
||||
const res = await action();
|
||||
|
|
@ -278,9 +281,7 @@ export function MakeOfferDialog({ listing, currentUserId, currencyLabel }: MakeO
|
|||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stock}× in stock · total{" "}
|
||||
<span className="font-mono">
|
||||
{fmt((Number(amount) || 0) * clampedQty)}
|
||||
</span>
|
||||
<span className="font-mono">{fmt((Number(amount) || 0) * clampedQty)}</span>
|
||||
</p>
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
</>
|
||||
|
|
@ -289,8 +290,8 @@ export function MakeOfferDialog({ listing, currentUserId, currencyLabel }: MakeO
|
|||
{outcome?.kind === "pending" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Offer of <span className="font-mono">{fmt(negotiation?.amount ?? 0)}</span> each
|
||||
for {negotiation?.quantity ?? stock}× sent. Waiting for the{" "}
|
||||
Offer of <span className="font-mono">{fmt(negotiation?.amount ?? 0)}</span> each for{" "}
|
||||
{negotiation?.quantity ?? stock}× sent. Waiting for the{" "}
|
||||
{isVendor ? "vendor" : "seller"} to respond.
|
||||
</p>
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
|
|
@ -386,7 +387,9 @@ export function MakeOfferDialog({ listing, currentUserId, currencyLabel }: MakeO
|
|||
</>
|
||||
)}
|
||||
|
||||
{(outcome?.kind === "accepted" || outcome?.kind === "rejected" || outcome?.kind === "closed") && (
|
||||
{(outcome?.kind === "accepted" ||
|
||||
outcome?.kind === "rejected" ||
|
||||
outcome?.kind === "closed") && (
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={acceptLocked}>
|
||||
{acceptLocked ? `Closing in ${acceptRemaining}s…` : "Close"}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -181,9 +181,7 @@ export function MarketView({
|
|||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
My listings
|
||||
</h2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{myListings.length} active
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{myListings.length} active</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-3">
|
||||
{myListings.map((listing) => (
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
|
|||
import { formatAmount } from "@/lib/banking/format";
|
||||
import { buildChatBubbles } from "@/lib/market/chatBubbles";
|
||||
import { ChatBubbleList } from "@/components/frontend/market/ChatBubbleList";
|
||||
import { HistoryIcon, ChevronDownIcon } from "lucide-react";
|
||||
import { ChevronDownIcon, HistoryIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
|
|
|
|||
|
|
@ -40,10 +40,7 @@ function assetName(listing: MarketListing | null): string {
|
|||
return typeof asset === "object" && asset ? asset.name : `Asset #${listing.asset}`;
|
||||
}
|
||||
|
||||
function counterpartyLabel(
|
||||
negotiation: MarketNegotiation,
|
||||
role: "buyer" | "seller",
|
||||
): string {
|
||||
function counterpartyLabel(negotiation: MarketNegotiation, role: "buyer" | "seller"): string {
|
||||
if (role === "buyer") {
|
||||
const listing = listingOf(negotiation);
|
||||
if (listing?.isAutoGenerated) {
|
||||
|
|
@ -55,9 +52,11 @@ function counterpartyLabel(
|
|||
}
|
||||
const seller = listing?.seller as unknown;
|
||||
if (typeof seller === "object" && seller && "username" in (seller as object)) {
|
||||
return (seller as { username?: string; displayName?: string }).displayName ||
|
||||
return (
|
||||
(seller as { username?: string; displayName?: string }).displayName ||
|
||||
(seller as { username?: string }).username ||
|
||||
"Seller";
|
||||
"Seller"
|
||||
);
|
||||
}
|
||||
return "Seller";
|
||||
}
|
||||
|
|
@ -128,7 +127,9 @@ function NegotiationRow({
|
|||
{counterpartyLabel(negotiation, role)} · {listing?.quantity ?? "?"}× in stock
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant={isOpen ? "default" : "secondary"}>{STATUS_LABELS[negotiation.status] ?? negotiation.status}</Badge>
|
||||
<Badge variant={isOpen ? "default" : "secondary"}>
|
||||
{STATUS_LABELS[negotiation.status] ?? negotiation.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
|
|
@ -223,7 +224,11 @@ interface NegotiationPanelProps {
|
|||
currencyLabel: string | null;
|
||||
}
|
||||
|
||||
export function NegotiationPanel({ negotiations, currentUserId, currencyLabel }: NegotiationPanelProps) {
|
||||
export function NegotiationPanel({
|
||||
negotiations,
|
||||
currentUserId,
|
||||
currencyLabel,
|
||||
}: NegotiationPanelProps) {
|
||||
const open = negotiations.filter((n) => n.status === "open");
|
||||
const closed = negotiations.filter((n) => n.status !== "open");
|
||||
|
||||
|
|
@ -255,9 +260,8 @@ export function NegotiationPanel({ negotiations, currentUserId, currencyLabel }:
|
|||
{open.map((negotiation) => {
|
||||
const role: "buyer" | "seller" =
|
||||
negotiation.buyer &&
|
||||
(typeof negotiation.buyer === "object"
|
||||
? negotiation.buyer.id
|
||||
: negotiation.buyer) === currentUserId
|
||||
(typeof negotiation.buyer === "object" ? negotiation.buyer.id : negotiation.buyer) ===
|
||||
currentUserId
|
||||
? "buyer"
|
||||
: "seller";
|
||||
return (
|
||||
|
|
@ -276,7 +280,8 @@ export function NegotiationPanel({ negotiations, currentUserId, currencyLabel }:
|
|||
<div className="flex flex-wrap gap-1.5">
|
||||
{closed.slice(0, 6).map((negotiation) => (
|
||||
<Badge key={negotiation.id} variant="secondary" className="font-normal">
|
||||
{assetName(listingOf(negotiation))} · {STATUS_LABELS[negotiation.status] ?? negotiation.status}
|
||||
{assetName(listingOf(negotiation))} ·{" "}
|
||||
{STATUS_LABELS[negotiation.status] ?? negotiation.status}
|
||||
</Badge>
|
||||
))}
|
||||
{closed.length > 6 && (
|
||||
|
|
|
|||
|
|
@ -27,7 +27,12 @@ export function PatienceMeter({ patience, className }: PatienceMeterProps) {
|
|||
<div className={cn("flex flex-col gap-1.5", className)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">Vendor patience</span>
|
||||
<span className={cn("font-mono text-xs", value >= 80 ? "text-red-400" : "text-muted-foreground")}>
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-xs",
|
||||
value >= 80 ? "text-red-400" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{value}%
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
import Link from "next/link";
|
||||
import { cn, pluralize } from "@/lib/utils";
|
||||
import type { RosterMember, RosterAssignment } from "@/app/(frontend)/roster/page";
|
||||
import type { RosterAssignment, RosterMember } from "@/app/(frontend)/roster/page";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { NetworkIcon, Building2Icon, ShieldIcon, ListIcon, CrownIcon } from "lucide-react";
|
||||
import { Building2Icon, CrownIcon, ListIcon, NetworkIcon, ShieldIcon } from "lucide-react";
|
||||
|
||||
const TYPE_ORDER: Record<string, number> = {
|
||||
division: 0,
|
||||
|
|
|
|||
|
|
@ -42,9 +42,7 @@ export function CapacityBar({ utilization, currentMass, maxMass, className }: Ca
|
|||
<div className={cn("flex flex-col gap-1", className)}>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">Capacity</span>
|
||||
<span className={cn("font-medium", colorClass)}>
|
||||
{Math.round(utilization)}%
|
||||
</span>
|
||||
<span className={cn("font-medium", colorClass)}>{Math.round(utilization)}%</span>
|
||||
</div>
|
||||
<Progress value={utilization} className={cn("h-1.5", progressClass)} />
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Item, ItemContent, ItemHeader, ItemTitle, ItemDescription } from "@/components/ui/item";
|
||||
import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import { BookOpenTextIcon, ScrollTextIcon } from "lucide-react";
|
||||
import { useGameTick } from "@/hooks/useGameTick";
|
||||
import { formatType } from "@/utils/event-log/formatType";
|
||||
|
|
@ -9,7 +9,6 @@ import {
|
|||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
|
|
@ -140,9 +139,7 @@ export function EventLedger({
|
|||
<ItemContent>
|
||||
{eventTypes.length > 1 && (
|
||||
<DropdownMenu>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Filter event logs...
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">Filter event logs...</span>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useCallback, useEffect } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Resource } from "@/payload-types";
|
||||
|
|
@ -73,10 +73,7 @@ export function GridItemComponent({ item, cellSize, onSelect, selected }: GridIt
|
|||
onClick={handleClick}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 opacity-20"
|
||||
style={{ backgroundColor: bgColor }}
|
||||
/>
|
||||
<div className="absolute inset-0 opacity-20" style={{ backgroundColor: bgColor }} />
|
||||
<div className="relative flex flex-col items-center justify-center gap-0.5 p-1 w-full h-full">
|
||||
{resource.icon && typeof resource.icon === "object" && "url" in resource.icon && (
|
||||
<img
|
||||
|
|
|
|||
|
|
@ -2,16 +2,16 @@
|
|||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ShuffleIcon,
|
||||
RotateCwIcon,
|
||||
TrashIcon,
|
||||
ScissorsIcon,
|
||||
LayoutGridIcon,
|
||||
ListIcon,
|
||||
RotateCwIcon,
|
||||
ScissorsIcon,
|
||||
ShuffleIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import { GridItem, getItemFootprint, getGridOccupancy, findEmptySpot } from "@/lib/logistics";
|
||||
import { findEmptySpot, getGridOccupancy, getItemFootprint, GridItem } from "@/lib/logistics";
|
||||
import { Resource } from "@/payload-types";
|
||||
import { cn, pluralize } from "@/lib/utils";
|
||||
import { pluralize } from "@/lib/utils";
|
||||
|
||||
interface GridToolbarProps {
|
||||
gridWidth: number;
|
||||
|
|
@ -41,9 +41,10 @@ export function GridToolbar({
|
|||
const occupancy = getGridOccupancy(storedResources, gridWidth, gridHeight);
|
||||
const res = selectedItem ? (selectedItem.resource as Resource) : null;
|
||||
const fp = res ? getItemFootprint(res, selectedItem!.rotated) : null;
|
||||
const canSplit = selectedItem && res && fp
|
||||
? findEmptySpot(storedResources, fp.width, fp.height, gridWidth, gridHeight) !== null
|
||||
: false;
|
||||
const canSplit =
|
||||
selectedItem && res && fp
|
||||
? findEmptySpot(storedResources, fp.width, fp.height, gridWidth, gridHeight) !== null
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
|
|
@ -78,18 +79,15 @@ export function GridToolbar({
|
|||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{res.name}</div>
|
||||
<div className="text-muted-foreground">
|
||||
{selectedItem.amount.toLocaleString()} {pluralize(selectedItem.amount, res.unitOfMeasure)}
|
||||
{selectedItem.amount.toLocaleString()}{" "}
|
||||
{pluralize(selectedItem.amount, res.unitOfMeasure)}
|
||||
{fp && ` · ${fp.width}x${fp.height}`}
|
||||
{selectedItem.rotated && " · rotated"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{res.rotatable !== false && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onRotate(selectedItem.id)}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => onRotate(selectedItem.id)}>
|
||||
<RotateCwIcon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -103,11 +101,7 @@ export function GridToolbar({
|
|||
<ScissorsIcon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onRemove(selectedItem.id)}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => onRemove(selectedItem.id)}>
|
||||
<TrashIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,27 +1,35 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback, useMemo, useRef, useEffect } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
type DragMoveEvent,
|
||||
DragOverlay,
|
||||
type DragStartEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragStartEvent,
|
||||
type DragMoveEvent,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { GameStructure, Structure, Resource } from "@/payload-types";
|
||||
import { GameStructure, Resource, Structure } from "@/payload-types";
|
||||
import {
|
||||
getItemFootprint,
|
||||
isValidPlacement,
|
||||
getGridOccupancy,
|
||||
getItemFootprint,
|
||||
type GridItem,
|
||||
isValidPlacement,
|
||||
} from "@/lib/logistics";
|
||||
import { GridItemComponent } from "./GridItem";
|
||||
import { GridCell } from "./GridCell";
|
||||
|
||||
function DragOverlayContent({ item, cellSize, rotated }: { item: GridItem; cellSize: number; rotated?: boolean }) {
|
||||
function DragOverlayContent({
|
||||
item,
|
||||
cellSize,
|
||||
rotated,
|
||||
}: {
|
||||
item: GridItem;
|
||||
cellSize: number;
|
||||
rotated?: boolean;
|
||||
}) {
|
||||
const resource = item.resource as Resource;
|
||||
const effective = rotated ?? item.rotated;
|
||||
const { width, height } = getItemFootprint(resource, effective);
|
||||
|
|
@ -60,7 +68,13 @@ function DragOverlayContent({ item, cellSize, rotated }: { item: GridItem; cellS
|
|||
);
|
||||
}
|
||||
|
||||
function findMergeTarget(items: GridItem[], dragged: GridItem, targetX: number, targetY: number, rotated?: boolean): GridItem | null {
|
||||
function findMergeTarget(
|
||||
items: GridItem[],
|
||||
dragged: GridItem,
|
||||
targetX: number,
|
||||
targetY: number,
|
||||
rotated?: boolean,
|
||||
): GridItem | null {
|
||||
const effective = rotated ?? dragged.rotated;
|
||||
const fp = getItemFootprint(dragged.resource as Resource, effective);
|
||||
const targetCells = new Set<string>();
|
||||
|
|
@ -172,32 +186,37 @@ export function InventoryGrid({
|
|||
cells.add(`${hoverPosition.x + dx},${hoverPosition.y + dy}`);
|
||||
}
|
||||
}
|
||||
const mergeTarget = findMergeTarget(storedResources, activeItem, hoverPosition.x, hoverPosition.y, pendingRotated);
|
||||
const valid = mergeTarget !== null || isValidPlacement(
|
||||
const mergeTarget = findMergeTarget(
|
||||
storedResources,
|
||||
activeItem.id,
|
||||
activeItem,
|
||||
hoverPosition.x,
|
||||
hoverPosition.y,
|
||||
fp.width,
|
||||
fp.height,
|
||||
gridWidth,
|
||||
gridHeight,
|
||||
pendingRotated,
|
||||
);
|
||||
const valid =
|
||||
mergeTarget !== null ||
|
||||
isValidPlacement(
|
||||
storedResources,
|
||||
activeItem.id,
|
||||
hoverPosition.x,
|
||||
hoverPosition.y,
|
||||
fp.width,
|
||||
fp.height,
|
||||
gridWidth,
|
||||
gridHeight,
|
||||
);
|
||||
return { cells, valid };
|
||||
}, [hoverPosition, activeItem, storedResources, gridWidth, gridHeight, pendingRotated]);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
const data = event.active.data.current;
|
||||
if (data?.item) {
|
||||
const item = data.item as GridItem;
|
||||
setActiveItem(item);
|
||||
setPendingRotated(item.rotated ?? false);
|
||||
}
|
||||
setHoverPosition(null);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
const data = event.active.data.current;
|
||||
if (data?.item) {
|
||||
const item = data.item as GridItem;
|
||||
setActiveItem(item);
|
||||
setPendingRotated(item.rotated ?? false);
|
||||
}
|
||||
setHoverPosition(null);
|
||||
}, []);
|
||||
|
||||
const handleDragMove = useCallback(
|
||||
(event: DragMoveEvent) => {
|
||||
|
|
@ -277,13 +296,13 @@ export function InventoryGrid({
|
|||
}, [onSelectItem]);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragMove={handleDragMove}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragMove={handleDragMove}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div ref={containerRef} className="rounded-lg border bg-muted/20 p-2">
|
||||
<div
|
||||
ref={gridRef}
|
||||
|
|
@ -322,7 +341,7 @@ export function InventoryGrid({
|
|||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
|
|
|
|||
|
|
@ -18,11 +18,7 @@ export function ManageStorageButton({ structureId }: ManageStorageButtonProps) {
|
|||
<SettingsIcon className="size-3.5 mr-1.5" />
|
||||
Manage
|
||||
</Button>
|
||||
<ManageStorageDialog
|
||||
structureId={structureId}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
/>
|
||||
<ManageStorageDialog structureId={structureId} open={open} onOpenChange={setOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useTransition, useCallback } from "react";
|
||||
import { useCallback, useEffect, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -31,11 +31,11 @@ import {
|
|||
isResourceProhibited,
|
||||
isResourceWhitelisted,
|
||||
} from "@/lib/storageRules";
|
||||
import { GameStructure, Structure, Resource, GameVehicle, Vehicle } from "@/payload-types";
|
||||
import { GameStructure, GameVehicle, Resource, Structure, Vehicle } from "@/payload-types";
|
||||
import {
|
||||
ArrowDownToLineIcon,
|
||||
ArrowUpFromLineIcon,
|
||||
ArrowRightLeftIcon,
|
||||
ArrowUpFromLineIcon,
|
||||
Loader2Icon,
|
||||
TruckIcon,
|
||||
} from "lucide-react";
|
||||
|
|
@ -48,21 +48,13 @@ interface ManageStorageDialogProps {
|
|||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ManageStorageDialog({
|
||||
structureId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ManageStorageDialogProps) {
|
||||
const [structure, setStructure] = useState<
|
||||
(GameStructure & { type: Structure }) | null
|
||||
>(null);
|
||||
const [allStructures, setAllStructures] = useState<
|
||||
(GameStructure & { type: Structure })[]
|
||||
>([]);
|
||||
export function ManageStorageDialog({ structureId, open, onOpenChange }: ManageStorageDialogProps) {
|
||||
const [structure, setStructure] = useState<(GameStructure & { type: Structure }) | null>(null);
|
||||
const [allStructures, setAllStructures] = useState<(GameStructure & { type: Structure })[]>([]);
|
||||
const [allResources, setAllResources] = useState<Resource[]>([]);
|
||||
const [availableVehicles, setAvailableVehicles] = useState<
|
||||
(GameVehicle & { type: Vehicle })[]
|
||||
>([]);
|
||||
const [availableVehicles, setAvailableVehicles] = useState<(GameVehicle & { type: Vehicle })[]>(
|
||||
[],
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
|
|
@ -91,7 +83,9 @@ export function ManageStorageDialog({
|
|||
fetch(`/api/game-structures/${structureId}?depth=2`),
|
||||
fetch(`/api/game-structures?depth=2&limit=500`),
|
||||
fetch(`/api/resources?depth=0&limit=500`),
|
||||
fetch(`/api/game-vehicles?depth=2&limit=500&where[deployedAt][equals]=${structureId}&where[status][equals]=idle`),
|
||||
fetch(
|
||||
`/api/game-vehicles?depth=2&limit=500&where[deployedAt][equals]=${structureId}&where[status][equals]=idle`,
|
||||
),
|
||||
]);
|
||||
|
||||
const anyUnauthorized = [structRes, allRes, resourcesRes, vehiclesRes].some(
|
||||
|
|
@ -111,9 +105,7 @@ export function ManageStorageDialog({
|
|||
const vehiclesData = await vehiclesRes.json();
|
||||
setStructure(structData);
|
||||
setAllStructures(
|
||||
allData.docs.filter(
|
||||
(s: GameStructure & { type: Structure }) => s.id !== structureId,
|
||||
),
|
||||
allData.docs.filter((s: GameStructure & { type: Structure }) => s.id !== structureId),
|
||||
);
|
||||
setAllResources(resourcesData.docs ?? []);
|
||||
setAvailableVehicles(vehiclesData.docs ?? []);
|
||||
|
|
@ -177,18 +169,12 @@ export function ManageStorageDialog({
|
|||
): number => {
|
||||
const grid = (s.storedResources ?? []).reduce((sum, e) => {
|
||||
const res = e.resource;
|
||||
const rid =
|
||||
typeof res === "object" && res !== null && "id" in res
|
||||
? res.id
|
||||
: (res as number);
|
||||
const rid = typeof res === "object" && res !== null && "id" in res ? res.id : (res as number);
|
||||
return sum + (rid === resourceId ? e.amount : 0);
|
||||
}, 0);
|
||||
const voidAmount = (s.voidStorage ?? []).reduce((sum, e) => {
|
||||
const res = e.resource;
|
||||
const rid =
|
||||
typeof res === "object" && res !== null && "id" in res
|
||||
? res.id
|
||||
: (res as number);
|
||||
const rid = typeof res === "object" && res !== null && "id" in res ? res.id : (res as number);
|
||||
return sum + (rid === resourceId ? e.amount : 0);
|
||||
}, 0);
|
||||
return grid + voidAmount;
|
||||
|
|
@ -230,15 +216,11 @@ export function ManageStorageDialog({
|
|||
reasons.push({ value: allowance, label: "storage allowance" });
|
||||
}
|
||||
if (reasons.length === 0) return undefined;
|
||||
const binding = reasons.reduce(
|
||||
(min, r) => (r.value < min.value ? r : min),
|
||||
reasons[0],
|
||||
);
|
||||
const binding = reasons.reduce((min, r) => (r.value < min.value ? r : min), reasons[0]);
|
||||
return { max: binding.value, reason: binding.label };
|
||||
};
|
||||
|
||||
const getDepositMax = (): number | undefined =>
|
||||
getDepositLimitInfoFor(depositResourceId)?.max;
|
||||
const getDepositMax = (): number | undefined => getDepositLimitInfoFor(depositResourceId)?.max;
|
||||
|
||||
const getWithdrawMax = (): number | undefined => {
|
||||
const stored = findStoredResource(withdrawResourceId);
|
||||
|
|
@ -263,9 +245,7 @@ export function ManageStorageDialog({
|
|||
if (destMaxMass != null) {
|
||||
const massPerUnit = stored.resource.massPerUnit ?? 0;
|
||||
if (massPerUnit > 0) {
|
||||
caps.push(
|
||||
Math.floor((destMaxMass - getMassOf(dest)) / massPerUnit),
|
||||
);
|
||||
caps.push(Math.floor((destMaxMass - getMassOf(dest)) / massPerUnit));
|
||||
}
|
||||
}
|
||||
return Math.min(...caps);
|
||||
|
|
@ -273,8 +253,7 @@ export function ManageStorageDialog({
|
|||
return stored.amount;
|
||||
};
|
||||
|
||||
const getTransferMax = (): number | undefined =>
|
||||
getTransferMaxFor(transferToId);
|
||||
const getTransferMax = (): number | undefined => getTransferMaxFor(transferToId);
|
||||
|
||||
const clampAmount = (value: string, max: number | undefined): string => {
|
||||
const num = Number(value);
|
||||
|
|
@ -333,13 +312,19 @@ export function ManageStorageDialog({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex flex-col gap-1.5 p-3 rounded-md border",
|
||||
isOverCapacity ? "border-red-500/30 bg-red-500/5" : "border-border bg-muted/30",
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1.5 p-3 rounded-md border",
|
||||
isOverCapacity ? "border-red-500/30 bg-red-500/5" : "border-border bg-muted/30",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{tab === "deposit" ? "After deposit" : tab === "withdraw" ? "After withdrawal" : "After transfer"}
|
||||
{tab === "deposit"
|
||||
? "After deposit"
|
||||
: tab === "withdraw"
|
||||
? "After withdrawal"
|
||||
: "After transfer"}
|
||||
</span>
|
||||
<span className={cn("font-medium", getColor(previewPercent))}>
|
||||
{Math.round(previewPercent)}%
|
||||
|
|
@ -347,9 +332,12 @@ export function ManageStorageDialog({
|
|||
</div>
|
||||
<Progress value={previewPercent} className={cn("h-1.5", getBarColor(previewPercent))} />
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{currentMass.toLocaleString()} kg → {previewMass.toLocaleString()} kg</span>
|
||||
<span>
|
||||
{currentMass.toLocaleString()} kg → {previewMass.toLocaleString()} kg
|
||||
</span>
|
||||
<span className={cn(isOverCapacity && "text-red-400 font-medium")}>
|
||||
{delta >= 0 ? "+" : ""}{delta.toLocaleString()} kg
|
||||
{delta >= 0 ? "+" : ""}
|
||||
{delta.toLocaleString()} kg
|
||||
{isOverCapacity && " (over capacity!)"}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -474,9 +462,7 @@ export function ManageStorageDialog({
|
|||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage Storage</DialogTitle>
|
||||
<DialogDescription>
|
||||
{structure?.name ?? "Loading..."}
|
||||
</DialogDescription>
|
||||
<DialogDescription>{structure?.name ?? "Loading..."}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
|
|
@ -505,7 +491,8 @@ export function ManageStorageDialog({
|
|||
<div className="flex justify-between">
|
||||
<span>Cargo</span>
|
||||
<span className="font-medium text-foreground">
|
||||
{shipmentAmount}× {allResources.find((r) => r.id === Number(shipmentResourceId))?.name ?? "Unknown"}
|
||||
{shipmentAmount}×{" "}
|
||||
{allResources.find((r) => r.id === Number(shipmentResourceId))?.name ?? "Unknown"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -531,67 +518,81 @@ export function ManageStorageDialog({
|
|||
) : (
|
||||
<div className="flex items-center gap-2 p-3 rounded-md border border-amber-500/30 bg-amber-500/5 text-sm text-amber-400">
|
||||
<TruckIcon className="size-4 shrink-0" />
|
||||
<span>No idle vehicles stationed at this structure. Deploy a vehicle here first to create shipments.</span>
|
||||
<span>
|
||||
No idle vehicles stationed at this structure. Deploy a vehicle here first to
|
||||
create shipments.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{shipmentVehicleId && (() => {
|
||||
const selectedVehicle = availableVehicles.find((v) => v.id === Number(shipmentVehicleId));
|
||||
const vType = selectedVehicle && typeof selectedVehicle.type === "object" ? selectedVehicle.type : null;
|
||||
if (!selectedVehicle || !vType) return null;
|
||||
|
||||
const destStructure = allStructures.find((s) => s.id === Number(shipmentDestId));
|
||||
const originCoords = structure?.coordinates as [number, number] | null;
|
||||
const destCoords = destStructure?.coordinates as [number, number] | null;
|
||||
let distance = 0;
|
||||
if (originCoords && destCoords) {
|
||||
distance = Math.sqrt(
|
||||
(destCoords[0] - originCoords[0]) ** 2 +
|
||||
(destCoords[1] - originCoords[1]) ** 2,
|
||||
{shipmentVehicleId &&
|
||||
(() => {
|
||||
const selectedVehicle = availableVehicles.find(
|
||||
(v) => v.id === Number(shipmentVehicleId),
|
||||
);
|
||||
}
|
||||
const vType =
|
||||
selectedVehicle && typeof selectedVehicle.type === "object"
|
||||
? selectedVehicle.type
|
||||
: null;
|
||||
if (!selectedVehicle || !vType) return null;
|
||||
|
||||
const consumptionRate = vType.fuel?.fuelConsumptionRate ?? 1;
|
||||
const fuelCost = Math.ceil(distance * consumptionRate);
|
||||
const speed = vType.maxSpeedOnRoad ?? vType.maxSpeedOffRoad ?? vType.maxAirspeed ?? 50;
|
||||
const transitHours = distance / speed;
|
||||
const canAfford = (selectedVehicle.currentFuel ?? 0) >= fuelCost;
|
||||
const destStructure = allStructures.find((s) => s.id === Number(shipmentDestId));
|
||||
const originCoords = structure?.coordinates as [number, number] | null;
|
||||
const destCoords = destStructure?.coordinates as [number, number] | null;
|
||||
let distance = 0;
|
||||
if (originCoords && destCoords) {
|
||||
distance = Math.sqrt(
|
||||
(destCoords[0] - originCoords[0]) ** 2 + (destCoords[1] - originCoords[1]) ** 2,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-1.5 p-3 rounded-md border border-border bg-muted/30 text-xs">
|
||||
<div className="flex justify-between text-muted-foreground">
|
||||
<span>Distance</span>
|
||||
<span className="font-mono">{distance.toFixed(1)} units</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-muted-foreground">
|
||||
<span>Fuel Cost</span>
|
||||
<span className={cn("font-mono", !canAfford && "text-red-400 font-medium")}>
|
||||
{fuelCost} units
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-muted-foreground">
|
||||
<span>Transit Time</span>
|
||||
<span className="font-mono">
|
||||
{transitHours < 1
|
||||
? `${Math.ceil(transitHours * 60)} min`
|
||||
: `${transitHours.toFixed(1)} hr`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-muted-foreground">
|
||||
<span>Vehicle Fuel</span>
|
||||
<span className="font-mono">{selectedVehicle.currentFuel?.toFixed(1)} / {vType.fuel?.fuelCapacity}</span>
|
||||
const consumptionRate = vType.fuel?.fuelConsumptionRate ?? 1;
|
||||
const fuelCost = Math.ceil(distance * consumptionRate);
|
||||
const speed =
|
||||
vType.maxSpeedOnRoad ?? vType.maxSpeedOffRoad ?? vType.maxAirspeed ?? 50;
|
||||
const transitHours = distance / speed;
|
||||
const canAfford = (selectedVehicle.currentFuel ?? 0) >= fuelCost;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-1.5 p-3 rounded-md border border-border bg-muted/30 text-xs">
|
||||
<div className="flex justify-between text-muted-foreground">
|
||||
<span>Distance</span>
|
||||
<span className="font-mono">{distance.toFixed(1)} units</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-muted-foreground">
|
||||
<span>Fuel Cost</span>
|
||||
<span className={cn("font-mono", !canAfford && "text-red-400 font-medium")}>
|
||||
{fuelCost} units
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-muted-foreground">
|
||||
<span>Transit Time</span>
|
||||
<span className="font-mono">
|
||||
{transitHours < 1
|
||||
? `${Math.ceil(transitHours * 60)} min`
|
||||
: `${transitHours.toFixed(1)} hr`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-muted-foreground">
|
||||
<span>Vehicle Fuel</span>
|
||||
<span className="font-mono">
|
||||
{selectedVehicle.currentFuel?.toFixed(1)} / {vType.fuel?.fuelCapacity}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{!canAfford && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-md border border-red-500/30 bg-red-500/5 text-sm text-red-400">
|
||||
<span>
|
||||
Vehicle does not have enough fuel for this trip. Need {fuelCost} units,
|
||||
has {selectedVehicle.currentFuel?.toFixed(1)}.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!canAfford && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-md border border-red-500/30 bg-red-500/5 text-sm text-red-400">
|
||||
<span>Vehicle does not have enough fuel for this trip. Need {fuelCost} units, has {selectedVehicle.currentFuel?.toFixed(1)}.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
|
|
@ -606,7 +607,12 @@ export function ManageStorageDialog({
|
|||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateShipment}
|
||||
disabled={isPending || !shipmentVehicleId || !shipmentDestId || availableVehicles.length === 0}
|
||||
disabled={
|
||||
isPending ||
|
||||
!shipmentVehicleId ||
|
||||
!shipmentDestId ||
|
||||
availableVehicles.length === 0
|
||||
}
|
||||
className="flex-1"
|
||||
>
|
||||
{isPending ? (
|
||||
|
|
@ -628,269 +634,273 @@ export function ManageStorageDialog({
|
|||
{Math.round(Math.min((currentMass / maxMass) * 100, 100))}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={Math.min((currentMass / maxMass) * 100, 100)}
|
||||
className="h-1.5"
|
||||
/>
|
||||
<Progress value={Math.min((currentMass / maxMass) * 100, 100)} className="h-1.5" />
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{currentMass.toLocaleString()} kg used</span>
|
||||
<span>{maxMass.toLocaleString()} kg max</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Tabs defaultValue="deposit" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="deposit" className="flex items-center gap-1.5">
|
||||
<ArrowDownToLineIcon className="size-3.5" />
|
||||
Deposit
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="withdraw" className="flex items-center gap-1.5">
|
||||
<ArrowUpFromLineIcon className="size-3.5" />
|
||||
Withdraw
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="transfer" className="flex items-center gap-1.5">
|
||||
<ArrowRightLeftIcon className="size-3.5" />
|
||||
Transfer
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<Tabs defaultValue="deposit" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="deposit" className="flex items-center gap-1.5">
|
||||
<ArrowDownToLineIcon className="size-3.5" />
|
||||
Deposit
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="withdraw" className="flex items-center gap-1.5">
|
||||
<ArrowUpFromLineIcon className="size-3.5" />
|
||||
Withdraw
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="transfer" className="flex items-center gap-1.5">
|
||||
<ArrowRightLeftIcon className="size-3.5" />
|
||||
Transfer
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="deposit" className="flex flex-col gap-4 pt-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Resource</Label>
|
||||
<Select
|
||||
value={depositResourceId}
|
||||
onValueChange={(val) => {
|
||||
setDepositResourceId(val);
|
||||
setDepositAmount(
|
||||
clampAmount(depositAmount, getDepositLimitInfoFor(val)?.max),
|
||||
);
|
||||
}}
|
||||
<TabsContent value="deposit" className="flex flex-col gap-4 pt-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Resource</Label>
|
||||
<Select
|
||||
value={depositResourceId}
|
||||
onValueChange={(val) => {
|
||||
setDepositResourceId(val);
|
||||
setDepositAmount(
|
||||
clampAmount(depositAmount, getDepositLimitInfoFor(val)?.max),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a resource..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allResources
|
||||
.filter((r) => isResourceAllowed(r.id))
|
||||
.map((resource) => {
|
||||
const cap = getAllowedDepositCap(resource.id);
|
||||
return (
|
||||
<SelectItem key={resource.id} value={String(resource.id)}>
|
||||
{resource.name}
|
||||
{cap !== undefined && (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
— {cap.toLocaleString()} max
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Amount</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max={getDepositMax()}
|
||||
value={depositAmount}
|
||||
onChange={(e) => setDepositAmount(clampAmount(e.target.value, getDepositMax()))}
|
||||
/>
|
||||
{getDepositLimitInfoFor(depositResourceId) !== undefined && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Max: {getDepositLimitInfoFor(depositResourceId)!.max.toLocaleString()}{" "}
|
||||
(limited by {getDepositLimitInfoFor(depositResourceId)!.reason})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{renderCapacityPreview("deposit")}
|
||||
<Button
|
||||
onClick={handleDeposit}
|
||||
disabled={isPending || !depositResourceId}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a resource..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allResources
|
||||
.filter((r) => isResourceAllowed(r.id))
|
||||
.map((resource) => {
|
||||
const cap = getAllowedDepositCap(resource.id);
|
||||
{isPending ? (
|
||||
<Loader2Icon className="size-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<ArrowDownToLineIcon className="size-4 mr-2" />
|
||||
)}
|
||||
Deposit
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="withdraw" className="flex flex-col gap-4 pt-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Resource</Label>
|
||||
<Select
|
||||
value={withdrawResourceId}
|
||||
onValueChange={(val) => {
|
||||
setWithdrawResourceId(val);
|
||||
const stored = findStoredResource(val);
|
||||
setWithdrawAmount(clampAmount(withdrawAmount, stored?.amount));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a resource..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{storedResources.map((entry) => {
|
||||
const resource = entry.resource as Resource;
|
||||
if (!resource || typeof resource === "number") return null;
|
||||
return (
|
||||
<SelectItem key={resource.id} value={String(resource.id)}>
|
||||
{resource.name}
|
||||
{cap !== undefined && (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
— {cap.toLocaleString()} max
|
||||
</span>
|
||||
)}
|
||||
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
|
||||
{resource.name} ({entry.amount.toLocaleString()}{" "}
|
||||
{pluralize(entry.amount, resource.unitOfMeasure)})
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Amount</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max={getDepositMax()}
|
||||
value={depositAmount}
|
||||
onChange={(e) => setDepositAmount(clampAmount(e.target.value, getDepositMax()))}
|
||||
/>
|
||||
{getDepositLimitInfoFor(depositResourceId) !== undefined && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Max:{" "}
|
||||
{getDepositLimitInfoFor(depositResourceId)!.max.toLocaleString()}{" "}
|
||||
(limited by{" "}
|
||||
{getDepositLimitInfoFor(depositResourceId)!.reason})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{renderCapacityPreview("deposit")}
|
||||
<Button
|
||||
onClick={handleDeposit}
|
||||
disabled={isPending || !depositResourceId}
|
||||
className="w-full"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2Icon className="size-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<ArrowDownToLineIcon className="size-4 mr-2" />
|
||||
)}
|
||||
Deposit
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="withdraw" className="flex flex-col gap-4 pt-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Resource</Label>
|
||||
<Select
|
||||
value={withdrawResourceId}
|
||||
onValueChange={(val) => {
|
||||
setWithdrawResourceId(val);
|
||||
const stored = findStoredResource(val);
|
||||
setWithdrawAmount(clampAmount(withdrawAmount, stored?.amount));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a resource..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{storedResources.map((entry) => {
|
||||
const resource = entry.resource as Resource;
|
||||
if (!resource || typeof resource === "number") return null;
|
||||
return (
|
||||
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
|
||||
{resource.name} ({entry.amount.toLocaleString()} {pluralize(entry.amount, resource.unitOfMeasure)})
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Amount</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max={getWithdrawMax()}
|
||||
value={withdrawAmount}
|
||||
onChange={(e) => setWithdrawAmount(clampAmount(e.target.value, getWithdrawMax()))}
|
||||
/>
|
||||
{getWithdrawMax() !== undefined && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Max: {getWithdrawMax()!.toLocaleString()} stored
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{renderCapacityPreview("withdraw")}
|
||||
<Button
|
||||
onClick={handleWithdraw}
|
||||
disabled={isPending || !withdrawResourceId}
|
||||
className="w-full"
|
||||
variant="destructive"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2Icon className="size-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<ArrowUpFromLineIcon className="size-4 mr-2" />
|
||||
)}
|
||||
Withdraw
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="transfer" className="flex flex-col gap-4 pt-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Destination</Label>
|
||||
<Select
|
||||
value={transferToId}
|
||||
onValueChange={(val) => {
|
||||
setTransferToId(val);
|
||||
setTransferAmount(
|
||||
clampAmount(transferAmount, getTransferMaxFor(val)),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a structure..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allStructures.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Resource</Label>
|
||||
<Select
|
||||
value={transferResourceId}
|
||||
onValueChange={(val) => {
|
||||
setTransferResourceId(val);
|
||||
const stored = findStoredResource(val);
|
||||
setTransferAmount(clampAmount(transferAmount, stored?.amount));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a resource..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{storedResources.map((entry) => {
|
||||
const resource = entry.resource as Resource;
|
||||
if (!resource || typeof resource === "number") return null;
|
||||
return (
|
||||
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
|
||||
{resource.name} ({entry.amount.toLocaleString()} {pluralize(entry.amount, resource.unitOfMeasure)})
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Amount</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max={getTransferMax()}
|
||||
value={transferAmount}
|
||||
onChange={(e) => setTransferAmount(clampAmount(e.target.value, getTransferMax()))}
|
||||
/>
|
||||
{getTransferMax() !== undefined && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Max: {getTransferMax()!.toLocaleString()} stored
|
||||
</span>
|
||||
)}
|
||||
{transferToId && transferResourceId && (() => {
|
||||
const dest = allStructures.find((s) => s.id === Number(transferToId));
|
||||
const destType = dest?.type;
|
||||
if (!dest || !destType) return null;
|
||||
const stored = findStoredResource(transferResourceId);
|
||||
if (!stored) return null;
|
||||
const resId = stored.resource.id;
|
||||
if (isResourceProhibited(destType, resId)) {
|
||||
return (
|
||||
<span className="text-xs text-red-400">
|
||||
Destination prohibits this resource.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (!isResourceWhitelisted(destType, resId)) {
|
||||
return (
|
||||
<span className="text-xs text-amber-400">
|
||||
Destination only accepts items on its allowed storage list — this resource is not permitted.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const cap = getResourceStorageCap(destType, resId);
|
||||
const destStored = getStoredAmountOf(dest, resId);
|
||||
return (
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Amount</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max={getWithdrawMax()}
|
||||
value={withdrawAmount}
|
||||
onChange={(e) =>
|
||||
setWithdrawAmount(clampAmount(e.target.value, getWithdrawMax()))
|
||||
}
|
||||
/>
|
||||
{getWithdrawMax() !== undefined && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{cap != null
|
||||
? `Destination allowance: ${Math.max(0, cap - destStored).toLocaleString()} / ${cap.toLocaleString()}`
|
||||
: "Destination has no per-item storage limit"}
|
||||
Max: {getWithdrawMax()!.toLocaleString()} stored
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{renderCapacityPreview("transfer")}
|
||||
<Button
|
||||
onClick={handleTransfer}
|
||||
disabled={isPending || !transferToId || !transferResourceId || getTransferMax() === 0}
|
||||
className="w-full"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2Icon className="size-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<ArrowRightLeftIcon className="size-4 mr-2" />
|
||||
)}
|
||||
Transfer
|
||||
</Button>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
{renderCapacityPreview("withdraw")}
|
||||
<Button
|
||||
onClick={handleWithdraw}
|
||||
disabled={isPending || !withdrawResourceId}
|
||||
className="w-full"
|
||||
variant="destructive"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2Icon className="size-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<ArrowUpFromLineIcon className="size-4 mr-2" />
|
||||
)}
|
||||
Withdraw
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="transfer" className="flex flex-col gap-4 pt-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Destination</Label>
|
||||
<Select
|
||||
value={transferToId}
|
||||
onValueChange={(val) => {
|
||||
setTransferToId(val);
|
||||
setTransferAmount(clampAmount(transferAmount, getTransferMaxFor(val)));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a structure..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allStructures.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Resource</Label>
|
||||
<Select
|
||||
value={transferResourceId}
|
||||
onValueChange={(val) => {
|
||||
setTransferResourceId(val);
|
||||
const stored = findStoredResource(val);
|
||||
setTransferAmount(clampAmount(transferAmount, stored?.amount));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a resource..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{storedResources.map((entry) => {
|
||||
const resource = entry.resource as Resource;
|
||||
if (!resource || typeof resource === "number") return null;
|
||||
return (
|
||||
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
|
||||
{resource.name} ({entry.amount.toLocaleString()}{" "}
|
||||
{pluralize(entry.amount, resource.unitOfMeasure)})
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Amount</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max={getTransferMax()}
|
||||
value={transferAmount}
|
||||
onChange={(e) =>
|
||||
setTransferAmount(clampAmount(e.target.value, getTransferMax()))
|
||||
}
|
||||
/>
|
||||
{getTransferMax() !== undefined && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Max: {getTransferMax()!.toLocaleString()} stored
|
||||
</span>
|
||||
)}
|
||||
{transferToId &&
|
||||
transferResourceId &&
|
||||
(() => {
|
||||
const dest = allStructures.find((s) => s.id === Number(transferToId));
|
||||
const destType = dest?.type;
|
||||
if (!dest || !destType) return null;
|
||||
const stored = findStoredResource(transferResourceId);
|
||||
if (!stored) return null;
|
||||
const resId = stored.resource.id;
|
||||
if (isResourceProhibited(destType, resId)) {
|
||||
return (
|
||||
<span className="text-xs text-red-400">
|
||||
Destination prohibits this resource.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (!isResourceWhitelisted(destType, resId)) {
|
||||
return (
|
||||
<span className="text-xs text-amber-400">
|
||||
Destination only accepts items on its allowed storage list — this
|
||||
resource is not permitted.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const cap = getResourceStorageCap(destType, resId);
|
||||
const destStored = getStoredAmountOf(dest, resId);
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{cap != null
|
||||
? `Destination allowance: ${Math.max(0, cap - destStored).toLocaleString()} / ${cap.toLocaleString()}`
|
||||
: "Destination has no per-item storage limit"}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{renderCapacityPreview("transfer")}
|
||||
<Button
|
||||
onClick={handleTransfer}
|
||||
disabled={
|
||||
isPending || !transferToId || !transferResourceId || getTransferMax() === 0
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2Icon className="size-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<ArrowRightLeftIcon className="size-4 mr-2" />
|
||||
)}
|
||||
Transfer
|
||||
</Button>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Resource, GameStructure } from "@/payload-types";
|
||||
import { GameStructure, Resource } from "@/payload-types";
|
||||
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
||||
import { pluralize } from "@/lib/utils";
|
||||
import {
|
||||
|
|
@ -10,7 +10,7 @@ import {
|
|||
ItemSeparator,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import { PackageOpenIcon, DropletIcon, ZapIcon, BrainIcon } from "lucide-react";
|
||||
import { BrainIcon, DropletIcon, PackageOpenIcon, ZapIcon } from "lucide-react";
|
||||
|
||||
interface ResourceListProps {
|
||||
storedResources: GameStructure["storedResources"];
|
||||
|
|
@ -77,7 +77,7 @@ export function ResourceList({ storedResources, maxCapacityMass }: ResourceListP
|
|||
<ItemMedia variant="icon">
|
||||
{resource.icon ? (
|
||||
<img
|
||||
src={typeof resource.icon === "object" ? resource.icon.url ?? "" : ""}
|
||||
src={typeof resource.icon === "object" ? (resource.icon.url ?? "") : ""}
|
||||
alt={resource.name}
|
||||
className="size-4 object-contain"
|
||||
/>
|
||||
|
|
@ -93,18 +93,18 @@ export function ResourceList({ storedResources, maxCapacityMass }: ResourceListP
|
|||
/>
|
||||
<span>{resource.name}</span>
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
{entry.amount.toLocaleString()} {pluralize(entry.amount, resource.unitOfMeasure)}
|
||||
{entry.amount.toLocaleString()}{" "}
|
||||
{pluralize(entry.amount, resource.unitOfMeasure)}
|
||||
</span>
|
||||
</ItemTitle>
|
||||
{description && (
|
||||
<ItemDescription className="line-clamp-1">
|
||||
{description}
|
||||
</ItemDescription>
|
||||
<ItemDescription className="line-clamp-1">{description}</ItemDescription>
|
||||
)}
|
||||
</ItemContent>
|
||||
<div className="text-right shrink-0">
|
||||
<div className="text-sm font-medium">
|
||||
{entry.amount.toLocaleString()} {pluralize(entry.amount, resource.unitOfMeasure)}
|
||||
{entry.amount.toLocaleString()}{" "}
|
||||
{pluralize(entry.amount, resource.unitOfMeasure)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{mass > 0 && `${mass.toLocaleString()} kg mass`}
|
||||
|
|
|
|||
|
|
@ -8,11 +8,19 @@ import { getCategoryLabel } from "@/lib/logistics";
|
|||
interface StorageFiltersProps {
|
||||
categories: Structure["category"][];
|
||||
factions: Faction[];
|
||||
onFilterChange: (filters: { category: Structure["category"] | null; factionId: number | null }) => void;
|
||||
onFilterChange: (filters: {
|
||||
category: Structure["category"] | null;
|
||||
factionId: number | null;
|
||||
}) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StorageFilters({ categories, factions, onFilterChange, className }: StorageFiltersProps) {
|
||||
export function StorageFilters({
|
||||
categories,
|
||||
factions,
|
||||
onFilterChange,
|
||||
className,
|
||||
}: StorageFiltersProps) {
|
||||
const [selectedCategory, setSelectedCategory] = useState<Structure["category"] | null>(null);
|
||||
const [selectedFaction, setSelectedFaction] = useState<number | null>(null);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { GameStructure, Faction, Structure } from "@/payload-types";
|
||||
import { Faction, GameStructure, Structure } from "@/payload-types";
|
||||
import { StructureCard } from "@/components/frontend/storage/StructureCard";
|
||||
import { StorageFilters } from "@/components/frontend/storage/StorageFilters";
|
||||
|
||||
|
|
@ -21,7 +21,11 @@ export function StorageGrid({ structures, categories, factions }: StorageGridPro
|
|||
if (filters.category && s.type.category !== filters.category) return false;
|
||||
if (filters.factionId) {
|
||||
const factionId =
|
||||
typeof s.faction === "object" && s.faction ? s.faction.id : typeof s.faction === "number" ? s.faction : null;
|
||||
typeof s.faction === "object" && s.faction
|
||||
? s.faction.id
|
||||
: typeof s.faction === "number"
|
||||
? s.faction
|
||||
: null;
|
||||
if (factionId !== filters.factionId) return false;
|
||||
}
|
||||
return true;
|
||||
|
|
@ -29,11 +33,7 @@ export function StorageGrid({ structures, categories, factions }: StorageGridPro
|
|||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<StorageFilters
|
||||
categories={categories}
|
||||
factions={factions}
|
||||
onFilterChange={setFilters}
|
||||
/>
|
||||
<StorageFilters categories={categories} factions={factions} onFilterChange={setFilters} />
|
||||
{filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<p className="text-sm">No structures found.</p>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,6 @@
|
|||
import { Structure } from "@/payload-types";
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemHeader,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
ShieldCheckIcon,
|
||||
ListRestartIcon,
|
||||
} from "lucide-react";
|
||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import { CheckCircleIcon, ListRestartIcon, ShieldCheckIcon, XCircleIcon } from "lucide-react";
|
||||
|
||||
interface StorageRulesProps {
|
||||
allowedStorage: Structure["allowedStorage"];
|
||||
|
|
@ -42,8 +32,8 @@ export function StorageRules({
|
|||
<div className="flex items-center gap-2 p-2.5 rounded-md border border-amber-500/30 bg-amber-500/5 text-xs text-amber-400">
|
||||
<ListRestartIcon className="size-3.5 shrink-0" />
|
||||
<span>
|
||||
Whitelist mode — only the items listed under "Allowed" can be
|
||||
stored here. All other items are rejected.
|
||||
Whitelist mode — only the items listed under "Allowed" can be stored here. All other
|
||||
items are rejected.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -68,9 +58,7 @@ export function StorageRules({
|
|||
>
|
||||
<CheckCircleIcon className="size-3" />
|
||||
{name}
|
||||
{rule.amount && (
|
||||
<span className="text-green-400/60">×{rule.amount}</span>
|
||||
)}
|
||||
{rule.amount && <span className="text-green-400/60">×{rule.amount}</span>}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -11,21 +11,15 @@ import {
|
|||
} from "@/components/ui/item";
|
||||
import { CapacityBar } from "@/components/frontend/storage/CapacityBar";
|
||||
import {
|
||||
calculateStoredMass,
|
||||
calculateUtilization,
|
||||
getCategoryLabel,
|
||||
getFunctionTypeLabel,
|
||||
getGridSlots,
|
||||
getResourceCount,
|
||||
calculateStoredMass,
|
||||
calculateUtilization,
|
||||
} from "@/lib/logistics";
|
||||
import { GameStructure, Faction, Structure } from "@/payload-types";
|
||||
import {
|
||||
BoxIcon,
|
||||
Building2Icon,
|
||||
ChevronRightIcon,
|
||||
Grid3x3Icon,
|
||||
MapPinIcon,
|
||||
} from "lucide-react";
|
||||
import { Faction, GameStructure, Structure } from "@/payload-types";
|
||||
import { BoxIcon, Building2Icon, ChevronRightIcon, Grid3x3Icon, MapPinIcon } from "lucide-react";
|
||||
|
||||
interface StructureCardProps {
|
||||
structure: GameStructure & {
|
||||
|
|
@ -46,7 +40,10 @@ export function StructureCard({ structure }: StructureCardProps) {
|
|||
|
||||
return (
|
||||
<Link href={`/logistics/structures/${structure.id}`}>
|
||||
<Item variant="outline" className="cursor-pointer group transition-all duration-200 hover:scale-[1.01] hover:[box-shadow:inset_0_2px_8px_rgba(255,255,255,0.33)]">
|
||||
<Item
|
||||
variant="outline"
|
||||
className="cursor-pointer group transition-all duration-200 hover:scale-[1.01] hover:[box-shadow:inset_0_2px_8px_rgba(255,255,255,0.33)]"
|
||||
>
|
||||
<ItemHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<ItemMedia variant="icon">
|
||||
|
|
@ -89,11 +86,7 @@ export function StructureCard({ structure }: StructureCardProps) {
|
|||
{structureType.xSize}×{structureType.ySize} grid
|
||||
</span>
|
||||
)}
|
||||
{functionLabel && (
|
||||
<span className="flex items-center gap-1">
|
||||
{functionLabel}
|
||||
</span>
|
||||
)}
|
||||
{functionLabel && <span className="flex items-center gap-1">{functionLabel}</span>}
|
||||
</div>
|
||||
{structure.coordinates && (
|
||||
<span className="flex items-center gap-1">
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { GameStructure, Structure, Resource } from "@/payload-types";
|
||||
import { GameStructure, Resource, Structure } from "@/payload-types";
|
||||
import { InventoryGrid } from "./InventoryGrid";
|
||||
import { GridToolbar } from "./GridToolbar";
|
||||
import { ResourceList } from "./ResourceList";
|
||||
|
|
@ -13,27 +13,21 @@ import {
|
|||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemSeparator,
|
||||
ItemMedia,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import {
|
||||
PackageOpenIcon,
|
||||
AlertTriangleIcon,
|
||||
DropletIcon,
|
||||
ZapIcon,
|
||||
BrainIcon,
|
||||
} from "lucide-react";
|
||||
import { AlertTriangleIcon, BrainIcon, DropletIcon, PackageOpenIcon, ZapIcon } from "lucide-react";
|
||||
import { EventLedger } from "./EventLedger";
|
||||
import {
|
||||
moveGridItem,
|
||||
mergeGridStacks,
|
||||
rotateGridItem,
|
||||
removeGridItem,
|
||||
splitGridStack,
|
||||
autoArrangeGrid,
|
||||
mergeGridStacks,
|
||||
moveGridItem,
|
||||
removeGridItem,
|
||||
removeResource,
|
||||
retrieveFromVoid,
|
||||
rotateGridItem,
|
||||
splitGridStack,
|
||||
} from "@/app/(frontend)/logistics/structures/actions";
|
||||
|
||||
function getResourceIcon(type: Resource["type"]) {
|
||||
|
|
@ -216,7 +210,8 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) {
|
|||
</ItemTitle>
|
||||
<ItemDescription className="flex items-center gap-2">
|
||||
<span>
|
||||
{voidStorageItems.length} item{voidStorageItems.length !== 1 ? "s" : ""} — these no longer fit in the grid.
|
||||
{voidStorageItems.length} item{voidStorageItems.length !== 1 ? "s" : ""} — these no
|
||||
longer fit in the grid.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -241,7 +236,7 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) {
|
|||
<ItemMedia variant="icon">
|
||||
{resource.icon ? (
|
||||
<img
|
||||
src={typeof resource.icon === "object" ? resource.icon.url ?? "" : ""}
|
||||
src={typeof resource.icon === "object" ? (resource.icon.url ?? "") : ""}
|
||||
alt={resource.name}
|
||||
className="size-4 object-contain"
|
||||
/>
|
||||
|
|
@ -260,7 +255,8 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) {
|
|||
</ItemContent>
|
||||
<div className="text-right shrink-0">
|
||||
<div className="text-sm font-medium">
|
||||
{entry.amount.toLocaleString()} {pluralize(entry.amount, resource.unitOfMeasure)}
|
||||
{entry.amount.toLocaleString()}{" "}
|
||||
{pluralize(entry.amount, resource.unitOfMeasure)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{mass > 0 && `${mass.toLocaleString()} kg`}
|
||||
|
|
|
|||
Loading…
Reference in a new issue