Organization Device Management Tab Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a “Quản lý thiết bị” tab to Organization Detail page where Super Admins can CRUD devices scoped to the organization.
Architecture: Reuse OrganizationUsersTab pattern with TanStack Query useDevices hook (filter by orgId), DeviceDrawer form component, and permission gating via getOrganizationUserCapabilities. Device table uses Ant Design Table inline (consistent with existing DeviceList). Auto-assign organizationId on create.
Tech Stack: React 19, TanStack Query v5, Ant Design v5, Zod, TypeScript
File Structure
frontend/src/
hooks/useDevices.ts # NEW - TanStack Query hook for device list
pages/organizations/
components/
OrganizationDevicesTab.tsx # NEW - tab component
OrganizationDevicesTab.spec.tsx # NEW - unit tests
DeviceDrawer.tsx # NEW - create/edit form drawer
OrganizationDetail.tsx # MODIFY - add devices tab item
frontend/src/components/common/
DataTable.tsx # ALREADY EXISTS - check usage
Task 1: Create useDevices TanStack Query Hook
Files:
Create:
frontend/src/hooks/useDevices.tsTest:
frontend/src/hooks/useDevices.spec.ts(optional, hook testing)Step 1: Create
frontend/src/hooks/useDevices.ts
import { useQuery } from '@tanstack/react-query';
import { deviceService } from '@/services/device.service';
import type { DeviceListQuery } from '@/types/device.types';
export function useDevices(query: DeviceListQuery) {
return useQuery({
queryKey: ['devices', query],
queryFn: () => deviceService.getAllDevices(query),
select: (res) => res.data,
});
}
- Step 2: Verify file compiles
Run: cd frontend && npx tsc --noEmit src/hooks/useDevices.ts
Expected: No errors (imports must resolve)
- Step 3: Commit
git add frontend/src/hooks/useDevices.ts
git commit -m "feat(frontend): add useDevices TanStack Query hook"
Task 2: Create DeviceDrawer Component
Files:
Create:
frontend/src/pages/organizations/components/DeviceDrawer.tsxModify:
frontend/src/pages/organizations/components/OrganizationDevicesTab.tsx(import only, drawer rendered inside tab)Step 1: Write the failing test
Create frontend/src/pages/organizations/components/OrganizationDevicesTab.spec.tsx with DeviceDrawer test:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DeviceDrawer } from './DeviceDrawer';
import { deviceTypeSchema } from '@/schemas/device.schema';
describe('DeviceDrawer', () => {
const mockOnClose = vi.fn();
const mockOnSubmit = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it('renders create mode when no device passed', () => {
render(
<DeviceDrawer
open={true}
onClose={mockOnClose}
onSubmit={mockOnSubmit}
organizationId="org-123"
/>
);
expect(screen.getByText('Thêm thiết bị mới')).toBeInTheDocument();
});
it('renders edit mode when device passed', () => {
const device = {
id: 'dev-1',
name: 'Sensor 01',
serialNumber: 'SN-001',
type: 'sensor' as const,
model: 'Model X',
};
render(
<DeviceDrawer
open={true}
onClose={mockOnClose}
onSubmit={mockOnSubmit}
organizationId="org-123"
device={device}
/>
);
expect(screen.getByText('Chỉnh sửa thiết bị')).toBeInTheDocument();
expect(screen.getByDisplayValue('Sensor 01')).toBeInTheDocument();
expect(screen.getByDisplayValue('SN-001')).toBeInTheDocument();
});
it('calls onSubmit with correct data on create', async () => {
render(
<DeviceDrawer
open={true}
onClose={mockOnClose}
onSubmit={mockOnSubmit}
organizationId="org-123"
/>
);
await userEvent.type(screen.getByLabelText('Tên thiết bị'), 'New Device');
await userEvent.type(screen.getByLabelText('Mã serial'), 'SN-NEW');
await userEvent.selectOptions(screen.getByLabelText('Loại thiết bị'), 'sensor');
await userEvent.type(screen.getByLabelText('Mô hình'), 'Model A');
await userEvent.click(screen.getByRole('button', { name: 'Thêm thiết bị' }));
expect(mockOnSubmit).toHaveBeenCalledWith(
expect.objectContaining({
name: 'New Device',
serialNumber: 'SN-NEW',
type: 'sensor',
model: 'Model A',
organizationId: 'org-123',
})
);
});
it('calls onClose when Cancel is clicked', async () => {
render(
<DeviceDrawer
open={true}
onClose={mockOnClose}
onSubmit={mockOnSubmit}
organizationId="org-123"
/>
);
await userEvent.click(screen.getByRole('button', { name: 'Hủy' }));
expect(mockOnClose).toHaveBeenCalled();
});
});
- Step 2: Run test to verify it fails
Run: bun test frontend/src/pages/organizations/components/OrganizationDevicesTab.spec.tsx --run
Expected: FAIL — file does not exist yet
- Step 3: Implement
DeviceDrawercomponent
Create frontend/src/pages/organizations/components/DeviceDrawer.tsx:
import { useEffect } from 'react';
import { Drawer, Form, Input, Select, Button, Space } from 'antd';
import { useDevices } from '@/hooks/useDevices';
import { deviceTypeSchema } from '@/schemas/device.schema';
import type { Device, DeviceType } from '@/types/device.types';
import type { CreateDeviceDto, UpdateDeviceDto } from '@/types/device.types';
interface DeviceDrawerProps {
open: boolean;
onClose: () => void;
onSubmit: (data: CreateDeviceDto | UpdateDeviceDto) => Promise<void>;
organizationId: string;
device?: Device;
}
const DEVICE_TYPES = [
{ value: 'sensor', label: 'Sensor' },
{ value: 'gateway', label: 'Gateway' },
{ value: 'inverter', label: 'Inverter' },
{ value: 'feeder', label: 'Feeder' },
];
export function DeviceDrawer({
open,
onClose,
onSubmit,
organizationId,
device,
}: DeviceDrawerProps) {
const [form] = Form.useForm();
const isEdit = !!device;
useEffect(() => {
if (open) {
if (device) {
form.setFieldsValue({
name: device.name,
serialNumber: device.serialNumber,
type: device.type,
model: device.model,
});
} else {
form.resetFields();
}
}
}, [open, device, form]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
await onSubmit({ ...values, organizationId });
onClose();
} catch {
// validation failed
}
};
return (
<Drawer
title={isEdit ? 'Chỉnh sửa thiết bị' : 'Thêm thiết bị mới'}
placement="right"
styles={{ wrapper: { width: 500 } }}
open={open}
onClose={onClose}
destroyOnHidden
footer={
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={onClose}>Hủy</Button>
<Button type="primary" onClick={handleSubmit}>
{isEdit ? 'Lưu thay đổi' : 'Thêm thiết bị'}
</Button>
</Space>
}
>
<Form form={form} layout="vertical">
<Form.Item
label="Tên thiết bị"
name="name"
rules={[{ required: true, message: 'Vui lòng nhập tên thiết bị' }]}
>
<Input placeholder="Nhập tên thiết bị" />
</Form.Item>
<Form.Item
label="Mã serial"
name="serialNumber"
rules={[{ required: true, message: 'Vui lòng nhập mã serial' }]}
>
<Input placeholder="Nhập mã serial" />
</Form.Item>
<Form.Item
label="Loại thiết bị"
name="type"
rules={[{ required: true, message: 'Vui lòng chọn loại thiết bị' }]}
>
<Select placeholder="Chọn loại thiết bị">
{DEVICE_TYPES.map((t) => (
<Select.Option key={t.value} value={t.value}>
{t.label}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
label="Mô hình"
name="model"
rules={[{ required: true, message: 'Vui lòng nhập mô hình' }]}
>
<Input placeholder="Nhập mô hình thiết bị" />
</Form.Item>
</Form>
</Drawer>
);
}
- Step 4: Run tests to verify they pass
Run: bun test frontend/src/pages/organizations/components/OrganizationDevicesTab.spec.tsx --run
Expected: PASS
- Step 5: Commit
git add frontend/src/pages/organizations/components/DeviceDrawer.tsx
git commit -m "feat(frontend): add DeviceDrawer component for create/edit"
Task 3: Create OrganizationDevicesTab Component
Files:
Create:
frontend/src/pages/organizations/components/OrganizationDevicesTab.tsxStep 1: Write the failing test (add more tests to spec file from Task 2)
Add to frontend/src/pages/organizations/components/OrganizationDevicesTab.spec.tsx:
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { OrganizationDevicesTab } from './OrganizationDevicesTab';
import * as authStore from '@/stores/auth.store';
import * as deviceService from '@/services/device.service';
const mockOrganization = {
id: 'org-123',
name: 'Test Org',
code: 'TEST',
};
vi.mock('@/stores/auth.store', () => ({
useAuth: vi.fn(() => ({
actor: { id: 'user-1', role: 'super admin' },
organizationId: 'org-123',
})),
}));
describe('OrganizationDevicesTab', () => {
const queryClient = new QueryClient();
beforeEach(() => {
vi.clearAllMocks();
});
it('shows Create button for Super Admin', () => {
render(
<QueryClientProvider client={queryClient}>
<OrganizationDevicesTab organization={mockOrganization} />
</QueryClientProvider>
);
expect(screen.getByRole('button', { name: /Thêm thiết bị/i })).toBeInTheDocument();
});
it('does not show Create button for Admin', () => {
vi.mocked(authStore.useAuth).mockReturnValue({
actor: { id: 'user-2', role: 'admin' },
organizationId: 'org-123',
});
render(
<QueryClientProvider client={queryClient}>
<OrganizationDevicesTab organization={mockOrganization} />
</QueryClientProvider>
);
expect(screen.queryByRole('button', { name: /Thêm thiết bị/i })).not.toBeInTheDocument();
});
it('does not show Create button for Owner', () => {
vi.mocked(authStore.useAuth).mockReturnValue({
actor: { id: 'user-3', role: 'owner' },
organizationId: 'org-123',
});
render(
<QueryClientProvider client={queryClient}>
<OrganizationDevicesTab organization={mockOrganization} />
</QueryClientProvider>
);
expect(screen.queryByRole('button', { name: /Thêm thiết bị/i })).not.toBeInTheDocument();
});
it('opens drawer on Create button click', async () => {
render(
<QueryClientProvider client={queryClient}>
<OrganizationDevicesTab organization={mockOrganization} />
</QueryClientProvider>
);
await userEvent.click(screen.getByRole('button', { name: /Thêm thiết bị/i }));
expect(screen.getByText('Thêm thiết bị mới')).toBeInTheDocument();
});
it('shows empty state when no devices', async () => {
vi.mocked(deviceService.deviceService.getAllDevices).mockResolvedValue({
data: [],
total: 0,
} as any);
render(
<QueryClientProvider client={queryClient}>
<OrganizationDevicesTab organization={mockOrganization} />
</QueryClientProvider>
);
await waitFor(() => {
expect(screen.getByText(/không có thiết bị/i)).toBeInTheDocument();
});
});
});
- Step 2: Run test to verify it fails
Run: bun test frontend/src/pages/organizations/components/OrganizationDevicesTab.spec.tsx --run
Expected: FAIL — OrganizationDevicesTab does not exist
- Step 3: Implement
OrganizationDevicesTabcomponent
Create frontend/src/pages/organizations/components/OrganizationDevicesTab.tsx:
import { useState } from 'react';
import { Button, Space, Table, Tag, Popconfirm, message } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { useQueryClient } from '@tanstack/react-query';
import { useDevices } from '@/hooks/useDevices';
import { deviceService } from '@/services/device.service';
import { DeviceDrawer } from './DeviceDrawer';
import { getOrganizationUserCapabilities } from '../organizationUserCapabilities';
import { useAuth } from '@/stores/auth.store';
import type { Device } from '@/types/device.types';
import type { DeviceListQuery } from '@/types/device.types';
interface OrganizationDevicesTabProps {
organization: {
id: string;
name: string;
code: string;
};
}
const statusColors: Record<string, string> = {
online: 'green',
offline: 'default',
error: 'red',
};
export function OrganizationDevicesTab({ organization }: OrganizationDevicesTabProps) {
const { actor, organizationId: actorOrganizationId } = useAuth();
const actorRole = actor?.role ?? null;
const tabCapabilities = getOrganizationUserCapabilities({
actorRole,
actorOrganizationId,
selectedOrganizationId: organization.id,
});
const [page, setPage] = useState(1);
const [limit] = useState(20);
const [search, setSearch] = useState('');
const [drawerOpen, setDrawerOpen] = useState(false);
const [selectedDevice, setSelectedDevice] = useState<Device | undefined>();
const queryKey: DeviceListQuery = {
page,
limit,
search,
orgId: organization.id,
};
const { data, isLoading } = useDevices(queryKey);
const devices = data?.items ?? [];
const total = data?.total ?? 0;
const queryClient = useQueryClient();
const handleCreate = () => {
setSelectedDevice(undefined);
setDrawerOpen(true);
};
const handleEdit = (device: Device) => {
setSelectedDevice(device);
setDrawerOpen(true);
};
const handleDelete = async (deviceId: string) => {
try {
await deviceService.deleteDevice(deviceId);
message.success('Xóa thiết bị thành công');
queryClient.invalidateQueries({ queryKey: ['devices'] });
} catch {
message.error('Xóa thiết bị thất bại');
}
};
const handleDrawerSubmit = async (formData: any) => {
try {
if (selectedDevice) {
await deviceService.updateDevice(selectedDevice.id, formData);
message.success('Cập nhật thiết bị thành công');
} else {
await deviceService.createDevice(formData);
message.success('Thêm thiết bị thành công');
}
setDrawerOpen(false);
queryClient.invalidateQueries({ queryKey: ['devices'] });
} catch {
message.error(selectedDevice ? 'Cập nhật thất bại' : 'Thêm thiết bị thất bại');
}
};
const columns = [
{
title: 'Tên thiết bị',
dataIndex: 'name',
key: 'name',
render: (text: string) => <span className="font-medium">{text}</span>,
},
{
title: 'Mã serial',
dataIndex: 'serialNumber',
key: 'serialNumber',
},
{
title: 'Loại',
dataIndex: 'type',
key: 'type',
render: (type: string) => (
<Tag>
{type.charAt(0).toUpperCase() + type.slice(1)}
</Tag>
),
},
{
title: 'Mô hình',
dataIndex: 'model',
key: 'model',
},
{
title: 'Trạng thái',
dataIndex: 'status',
key: 'status',
render: (status: string) => (
<Tag color={statusColors[status] ?? 'default'}>
{status === 'online' ? 'Online' : status === 'offline' ? 'Offline' : 'Lỗi'}
</Tag>
),
},
];
if (tabCapabilities.canEditUser || tabCapabilities.canDeleteUser) {
columns.push({
title: 'Hành động',
key: 'actions',
render: (_: unknown, record: Device) => (
<Space>
{tabCapabilities.canEditUser && (
<Button
type="text"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
/>
)}
{tabCapabilities.canDeleteUser && (
<Popconfirm
title="Xóa thiết bị"
description="Bạn có chắc muốn xóa thiết bị này?"
onConfirm={() => handleDelete(record.id)}
okText="Xóa"
cancelText="Hủy"
>
<Button type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
)}
</Space>
),
});
}
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
{tabCapabilities.canCreateUser && (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleCreate}
>
Thêm thiết bị
</Button>
)}
</div>
<Table
columns={columns}
dataSource={devices}
rowKey="id"
loading={isLoading}
pagination={{
current: page,
pageSize: limit,
total,
onChange: setPage,
}}
/>
<DeviceDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
onSubmit={handleDrawerSubmit}
organizationId={organization.id}
device={selectedDevice}
/>
</div>
);
}
- Step 4: Run tests to verify they pass
Run: bun test frontend/src/pages/organizations/components/OrganizationDevicesTab.spec.tsx --run
Expected: PASS
- Step 5: Commit
git add frontend/src/pages/organizations/components/OrganizationDevicesTab.tsx
git commit -m "feat(frontend): add OrganizationDevicesTab component"
Task 4: Add Devices Tab to OrganizationDetail
Files:
Modify:
frontend/src/pages/organizations/OrganizationDetail.tsx:120-192Step 1: Read the file first
Run: head -n 200 frontend/src/pages/organizations/OrganizationDetail.tsx
- Step 2: Edit to add devices tab
Find the items array inside <Tabs> and add:
{
key: 'devices',
label: 'Quản lý thiết bị',
children: <OrganizationDevicesTab organization={organization} />,
}
Add import at top:
import { OrganizationDevicesTab } from './components/OrganizationDevicesTab';
- Step 3: Verify file compiles
Run: cd frontend && npx tsc --noEmit
Expected: No errors
- Step 4: Commit
git add frontend/src/pages/organizations/OrganizationDetail.tsx
git commit -m "feat(frontend): add devices tab to OrganizationDetail"
Task 5: Final Verification
- Step 1: Run full test suite
Run: bun test frontend/src/pages/organizations/ --run
Expected: All pass
- Step 2: Lint check
Run: bun run lint -- frontend/src/hooks/useDevices.ts frontend/src/pages/organizations/components/OrganizationDevicesTab.tsx frontend/src/pages/organizations/components/DeviceDrawer.tsx
Expected: No errors
- Step 3: Type check
Run: cd frontend && npx tsc --noEmit
Expected: No errors
Spec Coverage Checklist
| Spec Requirement | Task |
|---|---|
| New tab in Organization Detail page | Task 4 |
| OrganizationDevicesTab component | Task 3 |
| DeviceDrawer (create/edit form) | Task 2 |
| useDevices TanStack Query hook | Task 1 |
| Filter by orgId | Task 1, Task 3 |
| Auto-assign organizationId on create | Task 3 (handleDrawerSubmit passes orgId) |
| Super Admin full CRUD | Task 3 (permissions) |
| Admin/Owner read-only | Task 3 (permissions) |
| Form fields: name, serialNumber, type, model | Task 2 |
| Test file | Task 3 (spec file) |
Notes
- The spec says to use
DataTablebut existingDeviceListuses raw Ant DesignTableinline. UsingTabledirectly inOrganizationDevicesTabis consistent with existing codebase. - The spec reuses
canEditUser,canDeleteUser,canCreateUserfor device permissions — following that exactly. useDeviceshook is new — spec requires TanStack Query filtering, existing codebase uses Zustand store. New hook aligns with spec architecture.