Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions backend/src/opsce/assets/assets.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Asset } from './entities/asset.entity';

@Module({
imports: [TypeOrmModule.forFeature([Asset])],
exports: [TypeOrmModule],
})
export class AssetsModule {}
77 changes: 77 additions & 0 deletions backend/src/opsce/assets/entities/asset.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
DeleteDateColumn,
Index,
} from 'typeorm';

export enum AssetStatus {
ACTIVE = 'active',
INACTIVE = 'inactive',
MAINTENANCE = 'maintenance',
RETIRED = 'retired',
}

export enum AssetCondition {
EXCELLENT = 'excellent',
GOOD = 'good',
FAIR = 'fair',
POOR = 'poor',
}

@Entity('assets')
export class Asset {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column()
name: string;

@Column({ nullable: true })
description?: string;

@Index()
@Column()
category: string;

@Index()
@Column({ nullable: true })
serialNumber?: string;

@Index()
@Column({ type: 'enum', enum: AssetStatus, default: AssetStatus.ACTIVE })
status: AssetStatus;

@Column({ type: 'enum', enum: AssetCondition, default: AssetCondition.GOOD })
condition: AssetCondition;

@Column({ type: 'date', nullable: true })
purchaseDate?: Date;

@Column({ type: 'decimal', precision: 12, scale: 2, nullable: true })
purchaseValue?: number;

@Column({ type: 'decimal', precision: 12, scale: 2, nullable: true })
currentValue?: number;

@Column({ nullable: true })
assignedToUserId?: string;

@Column({ nullable: true })
departmentId?: string;

@Column({ nullable: true })
locationId?: string;

@CreateDateColumn()
createdAt: Date;

@UpdateDateColumn()
updatedAt: Date;

@DeleteDateColumn()
deletedAt?: Date;
}
9 changes: 9 additions & 0 deletions backend/src/opsce/departments/departments.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Department } from './entities/department.entity';

@Module({
imports: [TypeOrmModule.forFeature([Department])],
exports: [TypeOrmModule],
})
export class DepartmentsModule {}
44 changes: 44 additions & 0 deletions backend/src/opsce/departments/entities/department.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
} from 'typeorm';

@Entity('departments')
export class Department {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column({ unique: true })
name: string;

@Column({ nullable: true })
description?: string;

@Column({ nullable: true })
code?: string;

@Column({ default: true })
isActive: boolean;

@Column({ nullable: true })
parentId?: string;

@ManyToOne(() => Department, (d) => d.children, { nullable: true })
@JoinColumn({ name: 'parentId' })
parent?: Department;

@OneToMany(() => Department, (d) => d.parent)
children: Department[];

@CreateDateColumn()
createdAt: Date;

@UpdateDateColumn()
updatedAt: Date;
}
9 changes: 9 additions & 0 deletions backend/src/opsce/opsce.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AssetsModule } from './assets/assets.module';
import { DepartmentsModule } from './departments/departments.module';

@Module({
imports: [AssetsModule, DepartmentsModule],
exports: [AssetsModule, DepartmentsModule],
})
export class OpsceModule {}
163 changes: 163 additions & 0 deletions frontend/opsce/features/assets/BulkActionBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
'use client';

import { useState } from 'react';
import { X, Check } from 'lucide-react';

type BulkAction = 'status' | 'department' | 'location' | 'delete';

const ACTION_OPTIONS: { value: BulkAction; label: string }[] = [
{ value: 'status', label: 'Change Status' },
{ value: 'department', label: 'Reassign Department' },
{ value: 'location', label: 'Change Location' },
{ value: 'delete', label: 'Delete' },
];

interface BulkResult {
id: string;
success: boolean;
error?: string;
}

interface Props {
selectedIds: string[];
onClearSelection: () => void;
onActionComplete?: () => void;
}

export function BulkActionBar({ selectedIds, onClearSelection, onActionComplete }: Props) {
const [activeAction, setActiveAction] = useState<BulkAction | null>(null);
const [actionValue, setActionValue] = useState('');
const [results, setResults] = useState<BulkResult[]>([]);
const [loading, setLoading] = useState(false);

if (!selectedIds.length) return null;

const handleApply = async () => {
if (!activeAction) return;
setLoading(true);
try {
const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:6003/api';
const token =
typeof window !== 'undefined' ? localStorage.getItem('accessToken') : '';
const res = await fetch(`${API}/assets/bulk`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
ids: selectedIds,
action: activeAction,
value: actionValue,
}),
});
const data = await res.json();
setResults(
data.results ?? selectedIds.map((id) => ({ id, success: res.ok })),
);
if (res.ok) onActionComplete?.();
} catch {
setResults(
selectedIds.map((id) => ({ id, success: false, error: 'Request failed' })),
);
}
setLoading(false);
};

return (
<>
{/* Sticky action bar */}
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 bg-gray-900 text-white rounded-2xl shadow-xl px-5 py-3 flex items-center gap-4 min-w-[420px]">
<span className="text-sm font-medium">
{selectedIds.length} asset{selectedIds.length !== 1 ? 's' : ''} selected
</span>

<div className="flex items-center gap-2 flex-1">
<select
className="bg-gray-800 text-sm text-white rounded-lg px-2 py-1.5 border border-gray-700 flex-1"
value={activeAction ?? ''}
onChange={(e) => {
setActiveAction((e.target.value as BulkAction) || null);
setActionValue('');
setResults([]);
}}
>
<option value="">Choose action…</option>
{ACTION_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>

{activeAction && activeAction !== 'delete' && (
<input
className="bg-gray-800 text-sm text-white rounded-lg px-2 py-1.5 border border-gray-700 w-32"
placeholder="New value…"
value={actionValue}
onChange={(e) => setActionValue(e.target.value)}
/>
)}

<button
disabled={
!activeAction || loading || (activeAction !== 'delete' && !actionValue)
}
onClick={handleApply}
className="px-3 py-1.5 text-sm bg-white text-gray-900 rounded-lg font-medium disabled:opacity-40 hover:bg-gray-100 transition-colors"
>
{loading ? 'Applying…' : 'Apply'}
</button>
</div>

<button
onClick={() => {
onClearSelection();
setResults([]);
setActiveAction(null);
}}
className="text-gray-400 hover:text-white"
aria-label="Clear selection"
>
<X size={18} />
</button>
</div>

{/* Results modal */}
{results.length > 0 && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/40"
onClick={() => setResults([])}
/>
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 max-h-96 overflow-y-auto">
<h3 className="text-base font-semibold text-gray-900 mb-4">
Bulk Action Results
</h3>
<div className="space-y-2">
{results.map((r) => (
<div
key={r.id}
className={`flex items-center gap-2 text-sm ${
r.success ? 'text-green-600' : 'text-red-500'
}`}
>
{r.success ? <Check size={14} /> : <X size={14} />}
<span>
{r.id}: {r.success ? 'Success' : (r.error ?? 'Failed')}
</span>
</div>
))}
</div>
<button
onClick={() => setResults([])}
className="mt-4 w-full px-4 py-2 text-sm bg-gray-900 text-white rounded-lg hover:bg-gray-700"
>
Close
</button>
</div>
</div>
)}
</>
);
}
Loading
Loading