tr]:last:border-b-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+ return (
+
+ )
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+ return (
+ [role=checkbox]]:translate-y-[2px]",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+ return (
+ [role=checkbox]]:translate-y-[2px]",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableCaption({
+ className,
+ ...props
+}: React.ComponentProps<"caption">) {
+ return (
+
+ )
+}
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/admin/src/components/ui/tabs.tsx b/admin/src/components/ui/tabs.tsx
new file mode 100644
index 0000000..497ba5e
--- /dev/null
+++ b/admin/src/components/ui/tabs.tsx
@@ -0,0 +1,66 @@
+"use client"
+
+import * as React from "react"
+import * as TabsPrimitive from "@radix-ui/react-tabs"
+
+import { cn } from "@/lib/utils"
+
+function Tabs({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function TabsList({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function TabsTrigger({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function TabsContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent }
diff --git a/admin/src/components/ui/textarea.tsx b/admin/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..7f21b5e
--- /dev/null
+++ b/admin/src/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/admin/src/components/ui/tooltip.tsx b/admin/src/components/ui/tooltip.tsx
new file mode 100644
index 0000000..a4e90d4
--- /dev/null
+++ b/admin/src/components/ui/tooltip.tsx
@@ -0,0 +1,61 @@
+"use client"
+
+import * as React from "react"
+import * as TooltipPrimitive from "@radix-ui/react-tooltip"
+
+import { cn } from "@/lib/utils"
+
+function TooltipProvider({
+ delayDuration = 0,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function Tooltip({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function TooltipTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function TooltipContent({
+ className,
+ sideOffset = 0,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+ )
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
diff --git a/admin/src/components/users/user-form.tsx b/admin/src/components/users/user-form.tsx
new file mode 100644
index 0000000..2c7642b
--- /dev/null
+++ b/admin/src/components/users/user-form.tsx
@@ -0,0 +1,323 @@
+'use client';
+
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { z } from 'zod';
+import Link from 'next/link';
+import { ArrowLeft, Loader2 } from 'lucide-react';
+
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Checkbox } from '@/components/ui/checkbox';
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import type { UserDetail } from '@/types/models';
+
+// Unified schema - password validation handled conditionally
+const userFormSchema = z.object({
+ username: z.string().min(3, 'Username must be at least 3 characters'),
+ email: z.string().email('Invalid email address'),
+ password: z.string(),
+ first_name: z.string().optional(),
+ last_name: z.string().optional(),
+ phone_number: z.string().optional(),
+ is_active: z.boolean(),
+ is_staff: z.boolean(),
+ is_superuser: z.boolean(),
+});
+
+type FormValues = z.infer;
+export type UserFormData = Record;
+
+interface UserFormProps {
+ user?: UserDetail;
+ onSubmit: (data: UserFormData) => Promise;
+ isSubmitting: boolean;
+}
+
+export function UserForm({ user, onSubmit, isSubmitting }: UserFormProps) {
+ const isEditing = !!user;
+
+ const form = useForm({
+ resolver: zodResolver(userFormSchema),
+ defaultValues: {
+ username: user?.username ?? '',
+ email: user?.email ?? '',
+ password: '',
+ first_name: user?.first_name ?? '',
+ last_name: user?.last_name ?? '',
+ phone_number: user?.phone_number ?? '',
+ is_active: user?.is_active ?? true,
+ is_staff: user?.is_staff ?? false,
+ is_superuser: user?.is_superuser ?? false,
+ },
+ });
+
+ const handleSubmit = async (data: FormValues) => {
+ // Validate password for new users
+ if (!isEditing && data.password.length < 8) {
+ form.setError('password', { message: 'Password must be at least 8 characters' });
+ return;
+ }
+ // Remove empty password when editing
+ if (isEditing && !data.password) {
+ const { password, ...rest } = data;
+ await onSubmit(rest as UserFormData);
+ } else {
+ await onSubmit(data as UserFormData);
+ }
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+
+ {isEditing ? 'Edit User' : 'Create User'}
+
+
+ {isEditing
+ ? `Editing ${user.username}`
+ : 'Add a new user to the system'}
+
+
+
+
+
+
+
+ );
+}
diff --git a/admin/src/hooks/use-mobile.ts b/admin/src/hooks/use-mobile.ts
new file mode 100644
index 0000000..2b0fe1d
--- /dev/null
+++ b/admin/src/hooks/use-mobile.ts
@@ -0,0 +1,19 @@
+import * as React from "react"
+
+const MOBILE_BREAKPOINT = 768
+
+export function useIsMobile() {
+ const [isMobile, setIsMobile] = React.useState(undefined)
+
+ React.useEffect(() => {
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
+ const onChange = () => {
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+ }
+ mql.addEventListener("change", onChange)
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+ return () => mql.removeEventListener("change", onChange)
+ }, [])
+
+ return !!isMobile
+}
diff --git a/admin/src/lib/api.ts b/admin/src/lib/api.ts
new file mode 100644
index 0000000..cfb6c4c
--- /dev/null
+++ b/admin/src/lib/api.ts
@@ -0,0 +1,635 @@
+import axios, { AxiosError, type AxiosInstance } from 'axios';
+import type {
+ Notification,
+ NotificationDetail,
+ NotificationListParams,
+ NotificationStats,
+ Subscription,
+ SubscriptionDetail,
+ SubscriptionListParams,
+ UpdateSubscriptionRequest,
+ SubscriptionStats,
+ DashboardStats,
+} from '@/types/models';
+
+// In production, API is on same origin. In dev, use env var or localhost
+const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || '';
+
+// Create axios instance
+const api: AxiosInstance = axios.create({
+ baseURL: `${API_BASE_URL}/api/admin`,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+});
+
+// Add auth token to requests
+api.interceptors.request.use((config) => {
+ if (typeof window !== 'undefined') {
+ const token = localStorage.getItem('admin_token');
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`;
+ }
+ }
+ return config;
+});
+
+// Handle auth errors
+api.interceptors.response.use(
+ (response) => response,
+ (error: AxiosError) => {
+ if (error.response?.status === 401) {
+ if (typeof window !== 'undefined') {
+ localStorage.removeItem('admin_token');
+ window.location.href = '/admin/login/';
+ }
+ }
+ return Promise.reject(error);
+ }
+);
+
+// Types
+export interface AdminUser {
+ id: number;
+ email: string;
+ first_name: string;
+ last_name: string;
+ role: 'super_admin' | 'admin' | 'moderator';
+ is_active: boolean;
+ last_login?: string;
+ created_at: string;
+}
+
+export interface LoginRequest {
+ email: string;
+ password: string;
+}
+
+export interface LoginResponse {
+ token: string;
+ admin: AdminUser;
+}
+
+export interface ApiError {
+ error: string;
+}
+
+// Auth API
+export const authApi = {
+ login: async (data: LoginRequest): Promise => {
+ const response = await api.post('/auth/login', data);
+ return response.data;
+ },
+
+ logout: async (): Promise => {
+ await api.post('/auth/logout');
+ },
+
+ me: async (): Promise => {
+ const response = await api.get('/auth/me');
+ return response.data;
+ },
+
+ refreshToken: async (): Promise<{ token: string }> => {
+ const response = await api.post<{ token: string }>('/auth/refresh');
+ return response.data;
+ },
+};
+
+// Types for User management
+import type {
+ PaginatedResponse,
+ User,
+ UserDetail,
+ UserListParams,
+ CreateUserRequest,
+ UpdateUserRequest,
+ Residence,
+ ResidenceDetail,
+ ResidenceListParams,
+ CreateResidenceRequest,
+ UpdateResidenceRequest,
+ Task,
+ TaskDetail,
+ TaskListParams,
+ CreateTaskRequest,
+ UpdateTaskRequest,
+ Contractor,
+ ContractorDetail,
+ ContractorListParams,
+ CreateContractorRequest,
+ UpdateContractorRequest,
+ Document,
+ DocumentDetail,
+ DocumentListParams,
+ CreateDocumentRequest,
+ UpdateDocumentRequest,
+} from '@/types/models';
+
+// Users API
+export const usersApi = {
+ list: async (params?: UserListParams): Promise> => {
+ const response = await api.get>('/users', { params });
+ return response.data;
+ },
+
+ get: async (id: number): Promise => {
+ const response = await api.get(`/users/${id}`);
+ return response.data;
+ },
+
+ create: async (data: CreateUserRequest): Promise => {
+ const response = await api.post('/users', data);
+ return response.data;
+ },
+
+ update: async (id: number, data: UpdateUserRequest): Promise => {
+ const response = await api.put(`/users/${id}`, data);
+ return response.data;
+ },
+
+ delete: async (id: number): Promise => {
+ await api.delete(`/users/${id}`);
+ },
+
+ bulkDelete: async (ids: number[]): Promise => {
+ await api.delete('/users/bulk', { data: { ids } });
+ },
+};
+
+// Residences API
+export const residencesApi = {
+ list: async (params?: ResidenceListParams): Promise> => {
+ const response = await api.get>('/residences', { params });
+ return response.data;
+ },
+
+ get: async (id: number): Promise => {
+ const response = await api.get(`/residences/${id}`);
+ return response.data;
+ },
+
+ create: async (data: CreateResidenceRequest): Promise => {
+ const response = await api.post('/residences', data);
+ return response.data;
+ },
+
+ update: async (id: number, data: UpdateResidenceRequest): Promise => {
+ const response = await api.put(`/residences/${id}`, data);
+ return response.data;
+ },
+
+ delete: async (id: number): Promise => {
+ await api.delete(`/residences/${id}`);
+ },
+
+ bulkDelete: async (ids: number[]): Promise => {
+ await api.delete('/residences/bulk', { data: { ids } });
+ },
+};
+
+// Tasks API
+export const tasksApi = {
+ list: async (params?: TaskListParams): Promise> => {
+ const response = await api.get>('/tasks', { params });
+ return response.data;
+ },
+
+ get: async (id: number): Promise => {
+ const response = await api.get(`/tasks/${id}`);
+ return response.data;
+ },
+
+ create: async (data: CreateTaskRequest): Promise => {
+ const response = await api.post('/tasks', data);
+ return response.data;
+ },
+
+ update: async (id: number, data: UpdateTaskRequest): Promise => {
+ const response = await api.put(`/tasks/${id}`, data);
+ return response.data;
+ },
+
+ delete: async (id: number): Promise => {
+ await api.delete(`/tasks/${id}`);
+ },
+
+ bulkDelete: async (ids: number[]): Promise => {
+ await api.delete('/tasks/bulk', { data: { ids } });
+ },
+};
+
+// Contractors API
+export const contractorsApi = {
+ list: async (params?: ContractorListParams): Promise> => {
+ const response = await api.get>('/contractors', { params });
+ return response.data;
+ },
+
+ get: async (id: number): Promise => {
+ const response = await api.get(`/contractors/${id}`);
+ return response.data;
+ },
+
+ create: async (data: CreateContractorRequest): Promise => {
+ const response = await api.post('/contractors', data);
+ return response.data;
+ },
+
+ update: async (id: number, data: UpdateContractorRequest): Promise => {
+ const response = await api.put(`/contractors/${id}`, data);
+ return response.data;
+ },
+
+ delete: async (id: number): Promise => {
+ await api.delete(`/contractors/${id}`);
+ },
+
+ bulkDelete: async (ids: number[]): Promise => {
+ await api.delete('/contractors/bulk', { data: { ids } });
+ },
+};
+
+// Documents API
+export const documentsApi = {
+ list: async (params?: DocumentListParams): Promise> => {
+ const response = await api.get>('/documents', { params });
+ return response.data;
+ },
+
+ get: async (id: number): Promise => {
+ const response = await api.get(`/documents/${id}`);
+ return response.data;
+ },
+
+ create: async (data: CreateDocumentRequest): Promise => {
+ const response = await api.post('/documents', data);
+ return response.data;
+ },
+
+ update: async (id: number, data: UpdateDocumentRequest): Promise => {
+ const response = await api.put(`/documents/${id}`, data);
+ return response.data;
+ },
+
+ delete: async (id: number): Promise => {
+ await api.delete(`/documents/${id}`);
+ },
+
+ bulkDelete: async (ids: number[]): Promise => {
+ await api.delete('/documents/bulk', { data: { ids } });
+ },
+};
+
+// Notifications API
+import type { UpdateNotificationRequest, UpdateCompletionRequest } from '@/types/models';
+
+export const notificationsApi = {
+ list: async (params?: NotificationListParams): Promise> => {
+ const response = await api.get>('/notifications', { params });
+ return response.data;
+ },
+
+ get: async (id: number): Promise => {
+ const response = await api.get(`/notifications/${id}`);
+ return response.data;
+ },
+
+ update: async (id: number, data: UpdateNotificationRequest): Promise => {
+ const response = await api.put(`/notifications/${id}`, data);
+ return response.data;
+ },
+
+ delete: async (id: number): Promise => {
+ await api.delete(`/notifications/${id}`);
+ },
+
+ getStats: async (): Promise => {
+ const response = await api.get('/notifications/stats');
+ return response.data;
+ },
+
+ sendTestNotification: async (data: { user_id: number; title: string; body: string }): Promise<{ message: string; notification_id: number; devices: { ios: number; android: number } }> => {
+ const response = await api.post('/notifications/send-test', data);
+ return response.data;
+ },
+};
+
+// Emails API
+export const emailsApi = {
+ sendTestEmail: async (data: { user_id: number; subject: string; body: string }): Promise<{ message: string; to: string }> => {
+ const response = await api.post('/emails/send-test', data);
+ return response.data;
+ },
+};
+
+// Subscriptions API
+export const subscriptionsApi = {
+ list: async (params?: SubscriptionListParams): Promise> => {
+ const response = await api.get>('/subscriptions', { params });
+ return response.data;
+ },
+
+ get: async (id: number): Promise => {
+ const response = await api.get(`/subscriptions/${id}`);
+ return response.data;
+ },
+
+ update: async (id: number, data: UpdateSubscriptionRequest): Promise => {
+ const response = await api.put(`/subscriptions/${id}`, data);
+ return response.data;
+ },
+
+ getStats: async (): Promise => {
+ const response = await api.get('/subscriptions/stats');
+ return response.data;
+ },
+};
+
+// Dashboard API
+export const dashboardApi = {
+ getStats: async (): Promise => {
+ const response = await api.get('/dashboard/stats');
+ return response.data;
+ },
+};
+
+// Auth Tokens Types
+export interface AuthToken {
+ key: string;
+ user_id: number;
+ username: string;
+ email: string;
+ created: string;
+}
+
+export interface AuthTokenListParams {
+ page?: number;
+ per_page?: number;
+ search?: string;
+ sort_by?: string;
+ sort_dir?: 'asc' | 'desc';
+}
+
+// Auth Tokens API
+export const authTokensApi = {
+ list: async (params?: AuthTokenListParams): Promise> => {
+ const response = await api.get>('/auth-tokens', { params });
+ return response.data;
+ },
+
+ get: async (userId: number): Promise => {
+ const response = await api.get(`/auth-tokens/${userId}`);
+ return response.data;
+ },
+
+ revoke: async (userId: number): Promise => {
+ await api.delete(`/auth-tokens/${userId}`);
+ },
+
+ bulkRevoke: async (userIds: number[]): Promise => {
+ await api.delete('/auth-tokens/bulk', { data: { ids: userIds } });
+ },
+};
+
+// Task Completions Types
+export interface TaskCompletion {
+ id: number;
+ task_id: number;
+ task_title: string;
+ residence_id: number;
+ residence_name: string;
+ completed_by_id: number;
+ completed_by: string;
+ completed_at: string;
+ notes: string;
+ actual_cost: string | null;
+ photo_url: string;
+ created_at: string;
+}
+
+export interface CompletionListParams {
+ page?: number;
+ per_page?: number;
+ search?: string;
+ sort_by?: string;
+ sort_dir?: 'asc' | 'desc';
+ task_id?: number;
+ residence_id?: number;
+ user_id?: number;
+}
+
+// Completions API
+export const completionsApi = {
+ list: async (params?: CompletionListParams): Promise> => {
+ const response = await api.get>('/completions', { params });
+ return response.data;
+ },
+
+ get: async (id: number): Promise => {
+ const response = await api.get(`/completions/${id}`);
+ return response.data;
+ },
+
+ update: async (id: number, data: UpdateCompletionRequest): Promise => {
+ const response = await api.put(`/completions/${id}`, data);
+ return response.data;
+ },
+
+ delete: async (id: number): Promise => {
+ await api.delete(`/completions/${id}`);
+ },
+
+ bulkDelete: async (ids: number[]): Promise => {
+ await api.delete('/completions/bulk', { data: { ids } });
+ },
+};
+
+// Lookup Types
+export interface LookupItem {
+ id: number;
+ name: string;
+ display_order: number;
+ description?: string;
+ icon?: string;
+ color?: string;
+}
+
+export interface CreateLookupRequest {
+ name: string;
+ display_order?: number;
+ description?: string;
+ icon?: string;
+ color?: string;
+}
+
+export interface UpdateLookupRequest {
+ name?: string;
+ display_order?: number;
+ description?: string;
+ icon?: string;
+ color?: string;
+}
+
+interface LookupListResponse {
+ data: LookupItem[];
+ total: number;
+}
+
+// Lookups API Factory
+const createLookupApi = (endpoint: string) => ({
+ list: async (): Promise => {
+ const response = await api.get(`/lookups/${endpoint}`);
+ return response.data.data;
+ },
+ create: async (data: CreateLookupRequest): Promise => {
+ const response = await api.post(`/lookups/${endpoint}`, data);
+ return response.data;
+ },
+ update: async (id: number, data: UpdateLookupRequest): Promise => {
+ const response = await api.put(`/lookups/${endpoint}/${id}`, data);
+ return response.data;
+ },
+ delete: async (id: number): Promise => {
+ await api.delete(`/lookups/${endpoint}/${id}`);
+ },
+});
+
+// Lookups API
+export const lookupsApi = {
+ categories: createLookupApi('categories'),
+ priorities: createLookupApi('priorities'),
+ statuses: createLookupApi('statuses'),
+ frequencies: createLookupApi('frequencies'),
+ residenceTypes: createLookupApi('residence-types'),
+ specialties: createLookupApi('specialties'),
+};
+
+// Admin Users Types
+export interface ManagedAdminUser {
+ id: number;
+ email: string;
+ first_name: string;
+ last_name: string;
+ role: 'super_admin' | 'admin';
+ is_active: boolean;
+ last_login?: string;
+ created_at: string;
+}
+
+export interface AdminUserListParams {
+ page?: number;
+ per_page?: number;
+ search?: string;
+ sort_by?: string;
+ sort_dir?: 'asc' | 'desc';
+ role?: string;
+ is_active?: boolean;
+}
+
+export interface CreateAdminUserRequest {
+ email: string;
+ password: string;
+ first_name?: string;
+ last_name?: string;
+ role?: 'super_admin' | 'admin';
+ is_active?: boolean;
+}
+
+export interface UpdateAdminUserRequest {
+ email?: string;
+ password?: string;
+ first_name?: string;
+ last_name?: string;
+ role?: 'super_admin' | 'admin';
+ is_active?: boolean;
+}
+
+// Admin Users API
+export const adminUsersApi = {
+ list: async (params?: AdminUserListParams): Promise> => {
+ const response = await api.get>('/admin-users', { params });
+ return response.data;
+ },
+
+ get: async (id: number): Promise => {
+ const response = await api.get(`/admin-users/${id}`);
+ return response.data;
+ },
+
+ create: async (data: CreateAdminUserRequest): Promise => {
+ const response = await api.post('/admin-users', data);
+ return response.data;
+ },
+
+ update: async (id: number, data: UpdateAdminUserRequest): Promise => {
+ const response = await api.put(`/admin-users/${id}`, data);
+ return response.data;
+ },
+
+ delete: async (id: number): Promise => {
+ await api.delete(`/admin-users/${id}`);
+ },
+};
+
+// Notification Preferences Types
+export interface NotificationPreference {
+ id: number;
+ user_id: number;
+ username: string;
+ email: string;
+ task_due_soon: boolean;
+ task_overdue: boolean;
+ task_completed: boolean;
+ task_assigned: boolean;
+ residence_shared: boolean;
+ warranty_expiring: boolean;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface NotificationPrefsListParams {
+ page?: number;
+ per_page?: number;
+ search?: string;
+ sort_by?: string;
+ sort_dir?: 'asc' | 'desc';
+}
+
+export interface UpdateNotificationPrefRequest {
+ task_due_soon?: boolean;
+ task_overdue?: boolean;
+ task_completed?: boolean;
+ task_assigned?: boolean;
+ residence_shared?: boolean;
+ warranty_expiring?: boolean;
+}
+
+// Notification Preferences API
+export const notificationPrefsApi = {
+ list: async (params?: NotificationPrefsListParams): Promise> => {
+ const response = await api.get>('/notification-prefs', { params });
+ return response.data;
+ },
+
+ get: async (id: number): Promise