Compare commits

...

2 Commits

6 changed files with 409 additions and 3 deletions

View File

@ -0,0 +1,14 @@
[
{ "id": "BR001", "name": "Bengaluru", "companyId": "CMP001", "companyName": "CLRI", "status": "Active" },
{ "id": "BR002", "name": "Pune", "companyId": "CMP001", "companyName": "CLRI", "status": "Active" },
{ "id": "BR003", "name": "Hyderabad", "companyId": "CMP001", "companyName": "CLRI", "status": "Disabled" },
{ "id": "BR004", "name": "Mumbai", "companyId": "CMP002", "companyName": "TechNova Solutions", "status": "Active" },
{ "id": "BR005", "name": "Delhi", "companyId": "CMP002", "companyName": "TechNova Solutions", "status": "Active" },
{ "id": "BR006", "name": "Chennai", "companyId": "CMP003", "companyName": "Infotech Ltd", "status": "Active" },
{ "id": "BR007", "name": "Kolkata", "companyId": "CMP003", "companyName": "Infotech Ltd", "status": "Disabled" },
{ "id": "BR008", "name": "Ahmedabad", "companyId": "CMP004", "companyName": "Global Systems", "status": "Active" },
{ "id": "BR009", "name": "Jaipur", "companyId": "CMP004", "companyName": "Global Systems", "status": "Active" },
{ "id": "BR010", "name": "Surat", "companyId": "CMP005", "companyName": "Alpha Corp", "status": "Disabled" },
{ "id": "BR011", "name": "Lucknow", "companyId": "CMP006", "companyName": "Beta Industries", "status": "Active" },
{ "id": "BR012", "name": "Chandigarh", "companyId": "CMP006", "companyName": "Beta Industries", "status": "Active" }
]

View File

@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import { fetchMockData } from '../../../api/client';
export interface Branch {
id: string;
name: string;
companyId: string;
companyName: string;
status: 'Active' | 'Disabled';
}
export const useBranches = () => {
return useQuery<Branch[]>({
queryKey: ['branches'],
queryFn: () => fetchMockData<Branch[]>('master-data/branches.json'),
});
};

View File

@ -0,0 +1,101 @@
import { useState, useEffect } from 'react';
import { X } from 'lucide-react';
import type { Branch } from '../api/useBranchData';
import type { Company } from '../api/useCompanyData';
interface BranchFormModalProps {
branch: Branch | null; // null means Add mode, object means Edit mode
companies: Company[];
onClose: () => void;
onSave: (name: string, companyId: string, companyName: string, status: 'Active' | 'Disabled') => void;
}
export default function BranchFormModal({ branch, companies, onClose, onSave }: BranchFormModalProps) {
const [name, setName] = useState('');
const [companyId, setCompanyId] = useState('');
const [status, setStatus] = useState<'Active' | 'Disabled'>('Active');
useEffect(() => {
if (branch) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setName(branch.name);
setCompanyId(branch.companyId);
setStatus(branch.status);
} else {
setName('');
setCompanyId(companies.length > 0 ? companies[0].id : '');
setStatus('Active');
}
}, [branch, companies]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Find the company name from the selected ID to save it alongside
const selectedCompany = companies.find(c => c.id === companyId);
onSave(name, companyId, selectedCompany?.name || '', status);
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-app-card rounded-lg shadow-xl w-full max-w-md p-6 relative">
<button onClick={onClose} className="absolute top-4 right-4 text-text-muted hover:text-text-primary">
<X size={24} />
</button>
<h3 className="text-xl font-bold text-text-primary mb-6">
{branch ? 'Edit Branch' : 'Add New Branch'}
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">Branch Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
className="w-full px-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card"
placeholder="Enter branch name..."
/>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">Company</label>
<select
value={companyId}
onChange={(e) => setCompanyId(e.target.value)}
required
className="w-full px-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card"
>
{companies.length === 0 ? (
<option value="">No companies available</option>
) : (
companies.map(c => <option key={c.id} value={c.id}>{c.name}</option>)
)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">Status</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value as 'Active' | 'Disabled')}
className="w-full px-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card"
>
<option value="Active">Active</option>
<option value="Disabled">Disabled</option>
</select>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button type="button" onClick={onClose} className="px-4 py-2 bg-app-muted text-text-primary rounded-lg hover:bg-app-border transition-colors text-sm font-medium">
Cancel
</button>
<button type="submit" disabled={companies.length === 0} className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed">
{branch ? 'Update' : 'Add'} Branch
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,180 @@
import { useState, useMemo } from 'react';
import { ArrowUpDown, ArrowUp, ArrowDown, Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Pencil, Trash2 } from 'lucide-react';
import type { Branch } from '../api/useBranchData';
interface BranchTableProps {
records: Branch[];
onEdit: (branch: Branch) => void;
onDelete: (id: string) => void;
}
type SortKey = 'id' | 'name' | 'companyName' | 'status';
type SortDirection = 'ascending' | 'descending';
const getStatusClass = (status: string) => {
switch (status) {
case 'Active': return 'bg-present-100 text-present-700';
case 'Disabled': return 'bg-absent-100 text-absent-700';
default: return 'bg-app-muted text-text-secondary';
}
};
export default function BranchTable({ records, onEdit, onDelete }: BranchTableProps) {
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [sortConfig, setSortConfig] = useState<{ key: SortKey; direction: SortDirection }>({
key: 'name',
direction: 'ascending'
});
const filteredData = useMemo(() => {
if (!searchQuery) return records;
return records.filter(record =>
record.id.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.companyName.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.status.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [records, searchQuery]);
const sortedData = useMemo(() => {
const sortableData = [...filteredData];
sortableData.sort((a, b) => {
if (a[sortConfig.key] < b[sortConfig.key]) return sortConfig.direction === 'ascending' ? -1 : 1;
if (a[sortConfig.key] > b[sortConfig.key]) return sortConfig.direction === 'ascending' ? 1 : -1;
return 0;
});
return sortableData;
}, [filteredData, sortConfig]);
const totalPages = Math.ceil(sortedData.length / pageSize);
const isPaginationEnabled = sortedData.length > pageSize;
const paginatedData = useMemo(() => {
const startIndex = (currentPage - 1) * pageSize;
return sortedData.slice(startIndex, startIndex + pageSize);
}, [sortedData, currentPage, pageSize]);
const pageNumbers = useMemo(() => {
const pages = [];
const maxVisiblePages = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
let endPage = startPage + maxVisiblePages - 1;
if (endPage > totalPages) {
endPage = totalPages;
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
for (let i = startPage; i <= endPage; i++) pages.push(i);
return pages;
}, [currentPage, totalPages]);
const requestSort = (key: SortKey) => {
let direction: SortDirection = 'ascending';
if (sortConfig.key === key && sortConfig.direction === 'ascending') direction = 'descending';
setSortConfig({ key, direction });
setCurrentPage(1);
};
const handlePageSizeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setPageSize(Number(e.target.value));
setCurrentPage(1);
};
const getSortIcon = (key: SortKey) => {
if (sortConfig.key !== key) return <ArrowUpDown size={14} className="ml-1 text-text-light" />;
if (sortConfig.direction === 'ascending') return <ArrowUp size={14} className="ml-1 text-primary" />;
return <ArrowDown size={14} className="ml-1 text-primary" />;
};
return (
<div className="bg-app-card p-6 rounded-lg shadow-sm">
<div className="flex flex-col md:flex-row md:items-center justify-between mb-6 gap-4">
<h2 className="text-lg font-semibold text-text-primary">Branches List</h2>
<div className="relative w-full md:w-64">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-light" />
<input
type="text"
placeholder="Search id, name, company..."
value={searchQuery}
onChange={(e) => { setSearchQuery(e.target.value); setCurrentPage(1); }}
className="pl-9 pr-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card w-full"
/>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-app-border">
<thead>
<tr>
{(['id', 'name', 'companyName', 'status'] as SortKey[]).map((key) => (
<th
key={key}
onClick={() => requestSort(key)}
className="px-6 py-3 text-left text-xs font-semibold text-text-muted uppercase tracking-wider cursor-pointer hover:bg-app-muted transition-colors"
>
<div className="flex items-center">
{key === 'id' ? 'Branch ID' : key === 'companyName' ? 'Company' : key.charAt(0).toUpperCase() + key.slice(1)} {getSortIcon(key)}
</div>
</th>
))}
<th className="px-6 py-3 text-left text-xs font-semibold text-text-muted uppercase tracking-wider">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-app-border">
{paginatedData.length > 0 ? (
paginatedData.map((record) => (
<tr key={record.id} className="hover:bg-app-muted transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-primary">{record.id}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.name}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.companyName}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(record.status)}`}>{record.status}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm flex space-x-3">
<button onClick={() => onEdit(record)} className="text-primary hover:text-primary-hover flex items-center">
<Pencil size={16} className="mr-1" /> Edit
</button>
<button onClick={() => onDelete(record.id)} className="text-absent-500 hover:text-absent-700 flex items-center">
<Trash2 size={16} className="mr-1" /> Delete
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={5} className="px-6 py-8 text-center text-sm text-text-muted">No branches found.</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="flex flex-col md:flex-row md:items-center justify-between mt-4 pt-4 border-t border-app-border gap-4">
<div className="flex items-center space-x-2">
<span className="text-sm text-text-muted">Show</span>
<select value={pageSize} onChange={handlePageSizeChange} className="px-2 py-1 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card">
<option value={10}>10</option>
<option value={15}>15</option>
<option value={20}>20</option>
</select>
<span className="text-sm text-text-muted">entries</span>
</div>
{isPaginationEnabled && totalPages > 1 && (
<div className="flex items-center space-x-1">
<span className="text-sm text-text-muted mr-2 hidden sm:inline">Page {currentPage} of {totalPages}</span>
<button onClick={() => setCurrentPage(1)} disabled={currentPage === 1} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronsLeft size={16} /></button>
<button onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))} disabled={currentPage === 1} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronLeft size={16} /></button>
{pageNumbers.map(num => (
<button key={num} onClick={() => setCurrentPage(num)} className={`w-8 h-8 flex items-center justify-center border rounded-lg text-sm transition-colors ${currentPage === num ? 'bg-primary text-white border-primary' : 'border-app-border text-text-secondary hover:bg-app-muted'}`}>{num}</button>
))}
<button onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))} disabled={currentPage === totalPages} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronRight size={16} /></button>
<button onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronsRight size={16} /></button>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,93 @@
import { useState, useMemo } from 'react';
import { Network, Plus } from 'lucide-react';
import { toast } from 'sonner';
import Loader from '../../../components/ui/Loader';
import ErrorState from '../../../components/ui/ErrorState';
import BranchTable from '../components/BranchTable';
import BranchFormModal from '../components/BranchFormModal';
import { useBranches, type Branch } from '../api/useBranchData';
import { useCompanies } from '../api/useCompanyData';
export default function ManageBranches() {
const { data: apiRecords, isLoading: branchesLoading, isError: branchesError } = useBranches();
const { data: companies, isLoading: companiesLoading, isError: companiesError } = useCompanies();
const [localRecords, setLocalRecords] = useState<Branch[]>([]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingBranch, setEditingBranch] = useState<Branch | null>(null);
// Sync local state when API data loads
useMemo(() => {
// eslint-disable-next-line react-hooks/set-state-in-render
if (apiRecords) setLocalRecords(apiRecords);
}, [apiRecords]);
const handleAddClick = () => {
setEditingBranch(null);
setIsModalOpen(true);
};
const handleEditClick = (branch: Branch) => {
setEditingBranch(branch);
setIsModalOpen(true);
};
const handleDeleteClick = (id: string) => {
setLocalRecords(prev => prev.filter(b => b.id !== id));
toast.success("Branch deleted successfully.");
};
const handleSave = (name: string, companyId: string, companyName: string, status: 'Active' | 'Disabled') => {
if (editingBranch) {
// Edit existing
setLocalRecords(prev => prev.map(b => b.id === editingBranch.id ? { ...b, name, companyId, companyName, status } : b));
toast.success("Branch updated successfully.");
} else {
// Add new
const newId = `BR${String(localRecords.length + 1).padStart(3, '0')}`;
setLocalRecords(prev => [...prev, { id: newId, name, companyId, companyName, status }]);
toast.success("Branch added successfully.");
}
setIsModalOpen(false);
};
if (branchesLoading || companiesLoading) return <Loader message="Loading branches data..." />;
if (branchesError || companiesError) return <ErrorState message="Failed to load branches data." />;
return (
<div className="space-y-6">
{/* Header & Add Button */}
<div className="bg-app-card p-6 rounded-lg shadow-sm flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-center space-x-3">
<div className="p-3 bg-primary/10 rounded-full">
<Network className="text-primary" size={28} />
</div>
<div>
<h2 className="text-xl font-bold text-text-primary">Manage Branches</h2>
<p className="text-sm text-text-muted">Add, edit, or disable branch locations.</p>
</div>
</div>
<button
onClick={handleAddClick}
className="flex items-center justify-center px-4 py-2 bg-action text-white rounded-lg hover:bg-action-hover transition-colors font-medium"
>
<Plus size={18} className="mr-2" />
Add Branch
</button>
</div>
{/* Table */}
<BranchTable records={localRecords} onEdit={handleEditClick} onDelete={handleDeleteClick} />
{/* Modal */}
{isModalOpen && (
<BranchFormModal
branch={editingBranch}
companies={companies || []}
onClose={() => setIsModalOpen(false)}
onSave={handleSave}
/>
)}
</div>
);
}

View File

@ -1,6 +1,7 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import MasterDataDashboard from '../pages/MasterDataDashboard';
import ManageCompanies from '../pages/ManageCompanies'; // New Import
import ManageCompanies from '../pages/ManageCompanies';
import ManageBranches from '../pages/ManageBranches'; // New Import
const Placeholder = ({ title }: { title: string }) => (
<div className="bg-app-card p-6 rounded-lg shadow-sm">
@ -14,8 +15,8 @@ export default function MasterDataRouter() {
<Routes>
<Route path="/" element={<Navigate to="/master-data/dashboard" replace />} />
<Route path="/dashboard" element={<MasterDataDashboard />} />
<Route path="/company" element={<ManageCompanies />} /> {/* Updated Route */}
<Route path="/branches" element={<Placeholder title="Manage Branches" />} />
<Route path="/company" element={<ManageCompanies />} />
<Route path="/branches" element={<ManageBranches />} /> {/* Updated Route */}
<Route path="/departments" element={<Placeholder title="Manage Departments" />} />
<Route path="/designations" element={<Placeholder title="Manage Designations" />} />
<Route path="/employees" element={<Placeholder title="Manage Employees" />} />