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.ts

  • Test: 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.tsx

  • Modify: 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 DeviceDrawer component

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.tsx

  • Step 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 OrganizationDevicesTab component

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-192

  • Step 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 DataTable but existing DeviceList uses raw Ant Design Table inline. Using Table directly in OrganizationDevicesTab is consistent with existing codebase.
  • The spec reuses canEditUser, canDeleteUser, canCreateUser for device permissions — following that exactly.
  • useDevices hook is new — spec requires TanStack Query filtering, existing codebase uses Zustand store. New hook aligns with spec architecture.