initial: standalone repo from monorepo split
This commit is contained in:
162
src/app/(dashboard)/dashboard/settings/areas/page.tsx
Normal file
162
src/app/(dashboard)/dashboard/settings/areas/page.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { DataTable } from '@/components/ui/data-table';
|
||||
import { ActionIcon } from '@/components/ui/action-icon';
|
||||
import { Modal } from '@/components/ui/modal';
|
||||
import { FormModal } from '@/components/ui/form-modal';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
|
||||
interface Area {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
isActive: boolean;
|
||||
_count: { clients: number };
|
||||
}
|
||||
|
||||
export default function AreasPage() {
|
||||
const { toast } = useToast();
|
||||
const [areas, setAreas] = useState<Area[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Area | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const loadAreas = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get<{ data: Area[] }>('/areas');
|
||||
setAreas(res.data.data);
|
||||
} catch {
|
||||
toast('Failed to load areas', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
useEffect(() => { loadAreas(); }, [loadAreas]);
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.delete(`/areas/${deleteTarget.id}`);
|
||||
toast(`Area "${deleteTarget.name}" deleted`, 'success');
|
||||
setDeleteTarget(null);
|
||||
loadAreas();
|
||||
} catch (err: any) {
|
||||
toast(err.response?.data?.error || 'Failed to delete area', 'error');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = search
|
||||
? areas.filter((a) => a.name.toLowerCase().includes(search.toLowerCase()) || (a.description || '').toLowerCase().includes(search.toLowerCase()))
|
||||
: areas;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<p className="text-sm text-surface-500">Define service areas and zones for client assignment</p>
|
||||
<Button onClick={() => setShowCreate(!showCreate)} variant={showCreate ? 'secondary' : 'primary'}>
|
||||
{showCreate ? 'Cancel' : 'Add Area'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={filtered}
|
||||
loading={loading}
|
||||
keyExtractor={(a) => a.id}
|
||||
emptyTitle="No areas yet"
|
||||
emptyDescription="Create your first service area to start organizing clients by location."
|
||||
searchPlaceholder="Search areas..."
|
||||
searchValue={search}
|
||||
onSearchChange={setSearch}
|
||||
columns={[
|
||||
{ key: 'name', label: 'Name', sortable: true, render: (a: Area) => <span className="font-medium text-surface-800 dark:text-surface-200">{a.name}</span> },
|
||||
{ key: 'description', label: 'Description', render: (a: Area) => <span className="text-sm text-surface-500 dark:text-surface-400">{a.description || '—'}</span> },
|
||||
{ key: 'isActive', label: 'Status', sortable: true, render: (a: Area) => <Badge label={a.isActive ? 'Active' : 'Inactive'} variant={a.isActive ? 'success' : 'error'} /> },
|
||||
{ key: 'clients', label: 'Clients', align: 'right' as const, render: (a: Area) => <span className="text-sm text-surface-600 dark:text-surface-300">{a._count.clients}</span> },
|
||||
{
|
||||
key: 'actions', label: '', align: 'right',
|
||||
render: (a: Area) => (
|
||||
<div className="flex justify-end" onClick={(e) => e.stopPropagation()}>
|
||||
{a._count.clients === 0 && (
|
||||
<ActionIcon icon="trash" variant="danger" label="Delete" onClick={() => setDeleteTarget(a)} />
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={!!deleteTarget}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
title="Delete Area"
|
||||
description={`Are you sure you want to delete "${deleteTarget?.name}"? This action cannot be undone.`}
|
||||
variant="danger"
|
||||
confirmLabel="Delete Area"
|
||||
onConfirm={handleDelete}
|
||||
loading={deleting}
|
||||
/>
|
||||
|
||||
{showCreate && (
|
||||
<CreateAreaModal onCreated={() => { setShowCreate(false); loadAreas(); toast('Area created', 'success'); }} onClose={() => setShowCreate(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateAreaModal({ onCreated, onClose }: { onCreated: () => void; onClose: () => void }) {
|
||||
const { toast } = useToast();
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const ic = 'mt-1 block w-full rounded-lg border border-surface-200 dark:border-surface-600 bg-white dark:bg-surface-800 px-3.5 py-2.5 text-sm text-surface-900 dark:text-surface-100 placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:border-primary-500 dark:focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200';
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await api.post('/areas', { name, description: description || undefined });
|
||||
onCreated();
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to create area');
|
||||
toast(err.response?.data?.error || 'Failed to create area', 'error');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormModal open onClose={onClose} title="Create Area" description="Define a new service area for client assignment">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded-lg text-sm" role="alert">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor="area-name" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Area Name</label>
|
||||
<input id="area-name" type="text" required minLength={2} value={name} onChange={(e) => setName(e.target.value)}
|
||||
className={ic} placeholder="e.g. Barangay 1 - Centro" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="area-desc" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Description <span className="text-surface-400 dark:text-surface-500 font-normal">(optional)</span></label>
|
||||
<input id="area-desc" type="text" value={description} onChange={(e) => setDescription(e.target.value)}
|
||||
className={ic} placeholder="Description of the service area" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button type="button" variant="secondary" onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" loading={submitting}>Create Area</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user