feat(frontend): Add CRM and Projects modules frontend
- CRM module: Types, API client, hooks (useLeads, useOpportunities, usePipeline, useStages, useActivities), and pages (LeadsPage, OpportunitiesPage) - Projects module: Types, API client, hooks (useProjects, useTasks, useTaskBoard, useTaskStages, useTimesheets), and pages (ProjectsPage, TasksPage) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
54c14f87c8
commit
07987788f8
228
src/features/crm/api/crm.api.ts
Normal file
228
src/features/crm/api/crm.api.ts
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
import { api } from '@services/api/axios-instance';
|
||||||
|
import type {
|
||||||
|
Lead,
|
||||||
|
LeadCreateInput,
|
||||||
|
LeadUpdateInput,
|
||||||
|
LeadFilters,
|
||||||
|
LeadConvertInput,
|
||||||
|
LeadsResponse,
|
||||||
|
Opportunity,
|
||||||
|
OpportunityCreateInput,
|
||||||
|
OpportunityUpdateInput,
|
||||||
|
OpportunityFilters,
|
||||||
|
OpportunitiesResponse,
|
||||||
|
Pipeline,
|
||||||
|
Stage,
|
||||||
|
StageCreateInput,
|
||||||
|
StageUpdateInput,
|
||||||
|
StageType,
|
||||||
|
StagesResponse,
|
||||||
|
Activity,
|
||||||
|
ActivityCreateInput,
|
||||||
|
ActivityUpdateInput,
|
||||||
|
ActivityFilters,
|
||||||
|
ActivitiesResponse,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const CRM_BASE = '/api/crm';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Leads API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const leadsApi = {
|
||||||
|
getAll: async (filters: LeadFilters = {}): Promise<LeadsResponse> => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters.companyId) params.append('company_id', filters.companyId);
|
||||||
|
if (filters.status) params.append('status', filters.status);
|
||||||
|
if (filters.stageId) params.append('stage_id', filters.stageId);
|
||||||
|
if (filters.userId) params.append('user_id', filters.userId);
|
||||||
|
if (filters.source) params.append('source', filters.source);
|
||||||
|
if (filters.priority !== undefined) params.append('priority', String(filters.priority));
|
||||||
|
if (filters.search) params.append('search', filters.search);
|
||||||
|
if (filters.page) params.append('page', String(filters.page));
|
||||||
|
if (filters.limit) params.append('limit', String(filters.limit));
|
||||||
|
|
||||||
|
const response = await api.get<LeadsResponse>(`${CRM_BASE}/leads?${params}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: async (id: string): Promise<Lead> => {
|
||||||
|
const response = await api.get<Lead>(`${CRM_BASE}/leads/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (data: LeadCreateInput): Promise<Lead> => {
|
||||||
|
const response = await api.post<Lead>(`${CRM_BASE}/leads`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: string, data: LeadUpdateInput): Promise<Lead> => {
|
||||||
|
const response = await api.patch<Lead>(`${CRM_BASE}/leads/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: string): Promise<void> => {
|
||||||
|
await api.delete(`${CRM_BASE}/leads/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
convert: async (id: string, data: LeadConvertInput): Promise<{ opportunityId?: string; partnerId?: string }> => {
|
||||||
|
const response = await api.post<{ opportunityId?: string; partnerId?: string }>(
|
||||||
|
`${CRM_BASE}/leads/${id}/convert`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
markLost: async (id: string, reason?: string): Promise<Lead> => {
|
||||||
|
const response = await api.post<Lead>(`${CRM_BASE}/leads/${id}/lost`, { reason });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Opportunities API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const opportunitiesApi = {
|
||||||
|
getAll: async (filters: OpportunityFilters = {}): Promise<OpportunitiesResponse> => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters.companyId) params.append('company_id', filters.companyId);
|
||||||
|
if (filters.status) params.append('status', filters.status);
|
||||||
|
if (filters.stageId) params.append('stage_id', filters.stageId);
|
||||||
|
if (filters.userId) params.append('user_id', filters.userId);
|
||||||
|
if (filters.partnerId) params.append('partner_id', filters.partnerId);
|
||||||
|
if (filters.priority !== undefined) params.append('priority', String(filters.priority));
|
||||||
|
if (filters.search) params.append('search', filters.search);
|
||||||
|
if (filters.page) params.append('page', String(filters.page));
|
||||||
|
if (filters.limit) params.append('limit', String(filters.limit));
|
||||||
|
|
||||||
|
const response = await api.get<OpportunitiesResponse>(`${CRM_BASE}/opportunities?${params}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: async (id: string): Promise<Opportunity> => {
|
||||||
|
const response = await api.get<Opportunity>(`${CRM_BASE}/opportunities/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (data: OpportunityCreateInput): Promise<Opportunity> => {
|
||||||
|
const response = await api.post<Opportunity>(`${CRM_BASE}/opportunities`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: string, data: OpportunityUpdateInput): Promise<Opportunity> => {
|
||||||
|
const response = await api.patch<Opportunity>(`${CRM_BASE}/opportunities/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: string): Promise<void> => {
|
||||||
|
await api.delete(`${CRM_BASE}/opportunities/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
markWon: async (id: string): Promise<Opportunity> => {
|
||||||
|
const response = await api.post<Opportunity>(`${CRM_BASE}/opportunities/${id}/won`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
markLost: async (id: string, reason?: string): Promise<Opportunity> => {
|
||||||
|
const response = await api.post<Opportunity>(`${CRM_BASE}/opportunities/${id}/lost`, { reason });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getPipeline: async (companyId?: string): Promise<Pipeline> => {
|
||||||
|
const params = companyId ? `?company_id=${companyId}` : '';
|
||||||
|
const response = await api.get<Pipeline>(`${CRM_BASE}/opportunities/pipeline${params}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Stages API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const stagesApi = {
|
||||||
|
getAll: async (type?: StageType, companyId?: string): Promise<StagesResponse> => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (type) params.append('type', type);
|
||||||
|
if (companyId) params.append('company_id', companyId);
|
||||||
|
|
||||||
|
const response = await api.get<StagesResponse>(`${CRM_BASE}/stages?${params}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: async (id: string): Promise<Stage> => {
|
||||||
|
const response = await api.get<Stage>(`${CRM_BASE}/stages/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (data: StageCreateInput): Promise<Stage> => {
|
||||||
|
const response = await api.post<Stage>(`${CRM_BASE}/stages`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: string, data: StageUpdateInput): Promise<Stage> => {
|
||||||
|
const response = await api.patch<Stage>(`${CRM_BASE}/stages/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: string): Promise<void> => {
|
||||||
|
await api.delete(`${CRM_BASE}/stages/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
reorder: async (stageIds: string[]): Promise<void> => {
|
||||||
|
await api.post(`${CRM_BASE}/stages/reorder`, { stageIds });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Activities API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const activitiesApi = {
|
||||||
|
getAll: async (filters: ActivityFilters = {}): Promise<ActivitiesResponse> => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters.companyId) params.append('company_id', filters.companyId);
|
||||||
|
if (filters.type) params.append('type', filters.type);
|
||||||
|
if (filters.state) params.append('state', filters.state);
|
||||||
|
if (filters.userId) params.append('user_id', filters.userId);
|
||||||
|
if (filters.leadId) params.append('lead_id', filters.leadId);
|
||||||
|
if (filters.opportunityId) params.append('opportunity_id', filters.opportunityId);
|
||||||
|
if (filters.partnerId) params.append('partner_id', filters.partnerId);
|
||||||
|
if (filters.search) params.append('search', filters.search);
|
||||||
|
if (filters.page) params.append('page', String(filters.page));
|
||||||
|
if (filters.limit) params.append('limit', String(filters.limit));
|
||||||
|
|
||||||
|
const response = await api.get<ActivitiesResponse>(`${CRM_BASE}/activities?${params}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: async (id: string): Promise<Activity> => {
|
||||||
|
const response = await api.get<Activity>(`${CRM_BASE}/activities/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (data: ActivityCreateInput): Promise<Activity> => {
|
||||||
|
const response = await api.post<Activity>(`${CRM_BASE}/activities`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: string, data: ActivityUpdateInput): Promise<Activity> => {
|
||||||
|
const response = await api.patch<Activity>(`${CRM_BASE}/activities/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: string): Promise<void> => {
|
||||||
|
await api.delete(`${CRM_BASE}/activities/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
markDone: async (id: string): Promise<Activity> => {
|
||||||
|
const response = await api.post<Activity>(`${CRM_BASE}/activities/${id}/done`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
cancel: async (id: string): Promise<Activity> => {
|
||||||
|
const response = await api.post<Activity>(`${CRM_BASE}/activities/${id}/cancel`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
1
src/features/crm/api/index.ts
Normal file
1
src/features/crm/api/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './crm.api';
|
||||||
1
src/features/crm/hooks/index.ts
Normal file
1
src/features/crm/hooks/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './useCrm';
|
||||||
428
src/features/crm/hooks/useCrm.ts
Normal file
428
src/features/crm/hooks/useCrm.ts
Normal file
@ -0,0 +1,428 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { leadsApi, opportunitiesApi, stagesApi, activitiesApi } from '../api';
|
||||||
|
import type {
|
||||||
|
Lead,
|
||||||
|
LeadCreateInput,
|
||||||
|
LeadUpdateInput,
|
||||||
|
LeadFilters,
|
||||||
|
LeadStatus,
|
||||||
|
LeadSource,
|
||||||
|
LeadConvertInput,
|
||||||
|
Opportunity,
|
||||||
|
OpportunityCreateInput,
|
||||||
|
OpportunityUpdateInput,
|
||||||
|
OpportunityFilters,
|
||||||
|
OpportunityStatus,
|
||||||
|
Pipeline,
|
||||||
|
Stage,
|
||||||
|
StageCreateInput,
|
||||||
|
StageUpdateInput,
|
||||||
|
StageType,
|
||||||
|
Activity,
|
||||||
|
ActivityCreateInput,
|
||||||
|
ActivityUpdateInput,
|
||||||
|
ActivityFilters,
|
||||||
|
ActivityType,
|
||||||
|
ActivityState,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// useLeads Hook
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface UseLeadsOptions {
|
||||||
|
status?: LeadStatus;
|
||||||
|
stageId?: string;
|
||||||
|
userId?: string;
|
||||||
|
source?: LeadSource;
|
||||||
|
search?: string;
|
||||||
|
limit?: number;
|
||||||
|
autoFetch?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLeads(options: UseLeadsOptions = {}) {
|
||||||
|
const [leads, setLeads] = useState<Lead[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const { status, stageId, userId, source, search, limit = 20, autoFetch = true } = options;
|
||||||
|
|
||||||
|
const fetchLeads = useCallback(async (pageNum = 1) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const filters: LeadFilters = {
|
||||||
|
status,
|
||||||
|
stageId,
|
||||||
|
userId,
|
||||||
|
source,
|
||||||
|
search,
|
||||||
|
page: pageNum,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
const response = await leadsApi.getAll(filters);
|
||||||
|
setLeads(response.data);
|
||||||
|
setTotal(response.total);
|
||||||
|
setPage(response.page);
|
||||||
|
setTotalPages(response.totalPages);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error('Error fetching leads'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [status, stageId, userId, source, search, limit]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoFetch) {
|
||||||
|
fetchLeads(1);
|
||||||
|
}
|
||||||
|
}, [fetchLeads, autoFetch]);
|
||||||
|
|
||||||
|
const createLead = useCallback(async (data: LeadCreateInput): Promise<Lead> => {
|
||||||
|
const lead = await leadsApi.create(data);
|
||||||
|
await fetchLeads(page);
|
||||||
|
return lead;
|
||||||
|
}, [fetchLeads, page]);
|
||||||
|
|
||||||
|
const updateLead = useCallback(async (id: string, data: LeadUpdateInput): Promise<Lead> => {
|
||||||
|
const lead = await leadsApi.update(id, data);
|
||||||
|
await fetchLeads(page);
|
||||||
|
return lead;
|
||||||
|
}, [fetchLeads, page]);
|
||||||
|
|
||||||
|
const deleteLead = useCallback(async (id: string): Promise<void> => {
|
||||||
|
await leadsApi.delete(id);
|
||||||
|
await fetchLeads(page);
|
||||||
|
}, [fetchLeads, page]);
|
||||||
|
|
||||||
|
const convertLead = useCallback(async (id: string, data: LeadConvertInput): Promise<{ opportunityId?: string; partnerId?: string }> => {
|
||||||
|
const result = await leadsApi.convert(id, data);
|
||||||
|
await fetchLeads(page);
|
||||||
|
return result;
|
||||||
|
}, [fetchLeads, page]);
|
||||||
|
|
||||||
|
const markLeadLost = useCallback(async (id: string, reason?: string): Promise<Lead> => {
|
||||||
|
const lead = await leadsApi.markLost(id, reason);
|
||||||
|
await fetchLeads(page);
|
||||||
|
return lead;
|
||||||
|
}, [fetchLeads, page]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
leads,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
setPage: (p: number) => fetchLeads(p),
|
||||||
|
refresh: () => fetchLeads(page),
|
||||||
|
createLead,
|
||||||
|
updateLead,
|
||||||
|
deleteLead,
|
||||||
|
convertLead,
|
||||||
|
markLeadLost,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// useOpportunities Hook
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface UseOpportunitiesOptions {
|
||||||
|
status?: OpportunityStatus;
|
||||||
|
stageId?: string;
|
||||||
|
userId?: string;
|
||||||
|
partnerId?: string;
|
||||||
|
search?: string;
|
||||||
|
limit?: number;
|
||||||
|
autoFetch?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOpportunities(options: UseOpportunitiesOptions = {}) {
|
||||||
|
const [opportunities, setOpportunities] = useState<Opportunity[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const { status, stageId, userId, partnerId, search, limit = 20, autoFetch = true } = options;
|
||||||
|
|
||||||
|
const fetchOpportunities = useCallback(async (pageNum = 1) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const filters: OpportunityFilters = {
|
||||||
|
status,
|
||||||
|
stageId,
|
||||||
|
userId,
|
||||||
|
partnerId,
|
||||||
|
search,
|
||||||
|
page: pageNum,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
const response = await opportunitiesApi.getAll(filters);
|
||||||
|
setOpportunities(response.data);
|
||||||
|
setTotal(response.total);
|
||||||
|
setPage(response.page);
|
||||||
|
setTotalPages(response.totalPages);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error('Error fetching opportunities'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [status, stageId, userId, partnerId, search, limit]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoFetch) {
|
||||||
|
fetchOpportunities(1);
|
||||||
|
}
|
||||||
|
}, [fetchOpportunities, autoFetch]);
|
||||||
|
|
||||||
|
const createOpportunity = useCallback(async (data: OpportunityCreateInput): Promise<Opportunity> => {
|
||||||
|
const opp = await opportunitiesApi.create(data);
|
||||||
|
await fetchOpportunities(page);
|
||||||
|
return opp;
|
||||||
|
}, [fetchOpportunities, page]);
|
||||||
|
|
||||||
|
const updateOpportunity = useCallback(async (id: string, data: OpportunityUpdateInput): Promise<Opportunity> => {
|
||||||
|
const opp = await opportunitiesApi.update(id, data);
|
||||||
|
await fetchOpportunities(page);
|
||||||
|
return opp;
|
||||||
|
}, [fetchOpportunities, page]);
|
||||||
|
|
||||||
|
const deleteOpportunity = useCallback(async (id: string): Promise<void> => {
|
||||||
|
await opportunitiesApi.delete(id);
|
||||||
|
await fetchOpportunities(page);
|
||||||
|
}, [fetchOpportunities, page]);
|
||||||
|
|
||||||
|
const markWon = useCallback(async (id: string): Promise<Opportunity> => {
|
||||||
|
const opp = await opportunitiesApi.markWon(id);
|
||||||
|
await fetchOpportunities(page);
|
||||||
|
return opp;
|
||||||
|
}, [fetchOpportunities, page]);
|
||||||
|
|
||||||
|
const markLost = useCallback(async (id: string, reason?: string): Promise<Opportunity> => {
|
||||||
|
const opp = await opportunitiesApi.markLost(id, reason);
|
||||||
|
await fetchOpportunities(page);
|
||||||
|
return opp;
|
||||||
|
}, [fetchOpportunities, page]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
opportunities,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
setPage: (p: number) => fetchOpportunities(p),
|
||||||
|
refresh: () => fetchOpportunities(page),
|
||||||
|
createOpportunity,
|
||||||
|
updateOpportunity,
|
||||||
|
deleteOpportunity,
|
||||||
|
markWon,
|
||||||
|
markLost,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// usePipeline Hook
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export function usePipeline(companyId?: string) {
|
||||||
|
const [pipeline, setPipeline] = useState<Pipeline | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const fetchPipeline = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await opportunitiesApi.getPipeline(companyId);
|
||||||
|
setPipeline(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error('Error fetching pipeline'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [companyId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPipeline();
|
||||||
|
}, [fetchPipeline]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pipeline,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refresh: fetchPipeline,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// useStages Hook
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export function useStages(type?: StageType, companyId?: string) {
|
||||||
|
const [stages, setStages] = useState<Stage[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const fetchStages = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await stagesApi.getAll(type, companyId);
|
||||||
|
setStages(response.data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error('Error fetching stages'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [type, companyId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStages();
|
||||||
|
}, [fetchStages]);
|
||||||
|
|
||||||
|
const createStage = useCallback(async (data: StageCreateInput): Promise<Stage> => {
|
||||||
|
const stage = await stagesApi.create(data);
|
||||||
|
await fetchStages();
|
||||||
|
return stage;
|
||||||
|
}, [fetchStages]);
|
||||||
|
|
||||||
|
const updateStage = useCallback(async (id: string, data: StageUpdateInput): Promise<Stage> => {
|
||||||
|
const stage = await stagesApi.update(id, data);
|
||||||
|
await fetchStages();
|
||||||
|
return stage;
|
||||||
|
}, [fetchStages]);
|
||||||
|
|
||||||
|
const deleteStage = useCallback(async (id: string): Promise<void> => {
|
||||||
|
await stagesApi.delete(id);
|
||||||
|
await fetchStages();
|
||||||
|
}, [fetchStages]);
|
||||||
|
|
||||||
|
const reorderStages = useCallback(async (stageIds: string[]): Promise<void> => {
|
||||||
|
await stagesApi.reorder(stageIds);
|
||||||
|
await fetchStages();
|
||||||
|
}, [fetchStages]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
stages,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refresh: fetchStages,
|
||||||
|
createStage,
|
||||||
|
updateStage,
|
||||||
|
deleteStage,
|
||||||
|
reorderStages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// useActivities Hook
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface UseActivitiesOptions {
|
||||||
|
type?: ActivityType;
|
||||||
|
state?: ActivityState;
|
||||||
|
userId?: string;
|
||||||
|
leadId?: string;
|
||||||
|
opportunityId?: string;
|
||||||
|
partnerId?: string;
|
||||||
|
search?: string;
|
||||||
|
limit?: number;
|
||||||
|
autoFetch?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useActivities(options: UseActivitiesOptions = {}) {
|
||||||
|
const [activities, setActivities] = useState<Activity[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const { type, state, userId, leadId, opportunityId, partnerId, search, limit = 20, autoFetch = true } = options;
|
||||||
|
|
||||||
|
const fetchActivities = useCallback(async (pageNum = 1) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const filters: ActivityFilters = {
|
||||||
|
type,
|
||||||
|
state,
|
||||||
|
userId,
|
||||||
|
leadId,
|
||||||
|
opportunityId,
|
||||||
|
partnerId,
|
||||||
|
search,
|
||||||
|
page: pageNum,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
const response = await activitiesApi.getAll(filters);
|
||||||
|
setActivities(response.data);
|
||||||
|
setTotal(response.total);
|
||||||
|
setPage(response.page);
|
||||||
|
setTotalPages(response.totalPages);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error('Error fetching activities'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [type, state, userId, leadId, opportunityId, partnerId, search, limit]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoFetch) {
|
||||||
|
fetchActivities(1);
|
||||||
|
}
|
||||||
|
}, [fetchActivities, autoFetch]);
|
||||||
|
|
||||||
|
const createActivity = useCallback(async (data: ActivityCreateInput): Promise<Activity> => {
|
||||||
|
const activity = await activitiesApi.create(data);
|
||||||
|
await fetchActivities(page);
|
||||||
|
return activity;
|
||||||
|
}, [fetchActivities, page]);
|
||||||
|
|
||||||
|
const updateActivity = useCallback(async (id: string, data: ActivityUpdateInput): Promise<Activity> => {
|
||||||
|
const activity = await activitiesApi.update(id, data);
|
||||||
|
await fetchActivities(page);
|
||||||
|
return activity;
|
||||||
|
}, [fetchActivities, page]);
|
||||||
|
|
||||||
|
const deleteActivity = useCallback(async (id: string): Promise<void> => {
|
||||||
|
await activitiesApi.delete(id);
|
||||||
|
await fetchActivities(page);
|
||||||
|
}, [fetchActivities, page]);
|
||||||
|
|
||||||
|
const markActivityDone = useCallback(async (id: string): Promise<Activity> => {
|
||||||
|
const activity = await activitiesApi.markDone(id);
|
||||||
|
await fetchActivities(page);
|
||||||
|
return activity;
|
||||||
|
}, [fetchActivities, page]);
|
||||||
|
|
||||||
|
const cancelActivity = useCallback(async (id: string): Promise<Activity> => {
|
||||||
|
const activity = await activitiesApi.cancel(id);
|
||||||
|
await fetchActivities(page);
|
||||||
|
return activity;
|
||||||
|
}, [fetchActivities, page]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activities,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
setPage: (p: number) => fetchActivities(p),
|
||||||
|
refresh: () => fetchActivities(page),
|
||||||
|
createActivity,
|
||||||
|
updateActivity,
|
||||||
|
deleteActivity,
|
||||||
|
markActivityDone,
|
||||||
|
cancelActivity,
|
||||||
|
};
|
||||||
|
}
|
||||||
3
src/features/crm/index.ts
Normal file
3
src/features/crm/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './api/crm.api';
|
||||||
|
export * from './types';
|
||||||
|
export * from './hooks';
|
||||||
300
src/features/crm/types/crm.types.ts
Normal file
300
src/features/crm/types/crm.types.ts
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
// CRM Types - Leads, Opportunities, Activities, Stages
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Lead Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type LeadStatus = 'new' | 'contacted' | 'qualified' | 'converted' | 'lost';
|
||||||
|
export type LeadSource = 'website' | 'phone' | 'email' | 'referral' | 'social_media' | 'advertising' | 'event' | 'other';
|
||||||
|
|
||||||
|
export interface Lead {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
companyId: string;
|
||||||
|
name: string;
|
||||||
|
contactName?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
mobile?: string;
|
||||||
|
website?: string;
|
||||||
|
street?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
country?: string;
|
||||||
|
stageId?: string;
|
||||||
|
stageName?: string;
|
||||||
|
status: LeadStatus;
|
||||||
|
userId?: string;
|
||||||
|
userName?: string;
|
||||||
|
teamId?: string;
|
||||||
|
source?: LeadSource;
|
||||||
|
medium?: string;
|
||||||
|
campaign?: string;
|
||||||
|
priority: number;
|
||||||
|
probability: number;
|
||||||
|
expectedRevenue?: number;
|
||||||
|
description?: string;
|
||||||
|
tags?: string[];
|
||||||
|
convertedOpportunityId?: string;
|
||||||
|
lostReason?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeadCreateInput {
|
||||||
|
name: string;
|
||||||
|
contactName?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
mobile?: string;
|
||||||
|
website?: string;
|
||||||
|
street?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
country?: string;
|
||||||
|
stageId?: string;
|
||||||
|
userId?: string;
|
||||||
|
teamId?: string;
|
||||||
|
source?: LeadSource;
|
||||||
|
medium?: string;
|
||||||
|
campaign?: string;
|
||||||
|
priority?: number;
|
||||||
|
probability?: number;
|
||||||
|
expectedRevenue?: number;
|
||||||
|
description?: string;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeadUpdateInput extends Partial<LeadCreateInput> {
|
||||||
|
status?: LeadStatus;
|
||||||
|
lostReason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeadFilters {
|
||||||
|
companyId?: string;
|
||||||
|
status?: LeadStatus;
|
||||||
|
stageId?: string;
|
||||||
|
userId?: string;
|
||||||
|
source?: LeadSource;
|
||||||
|
priority?: number;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeadConvertInput {
|
||||||
|
createOpportunity?: boolean;
|
||||||
|
opportunityName?: string;
|
||||||
|
partnerId?: string;
|
||||||
|
createPartner?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Opportunity Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type OpportunityStatus = 'open' | 'won' | 'lost';
|
||||||
|
|
||||||
|
export interface Opportunity {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
companyId: string;
|
||||||
|
name: string;
|
||||||
|
partnerId: string;
|
||||||
|
partnerName?: string;
|
||||||
|
stageId?: string;
|
||||||
|
stageName?: string;
|
||||||
|
status: OpportunityStatus;
|
||||||
|
userId?: string;
|
||||||
|
userName?: string;
|
||||||
|
teamId?: string;
|
||||||
|
priority: number;
|
||||||
|
probability: number;
|
||||||
|
expectedRevenue?: number;
|
||||||
|
expectedCloseDate?: string;
|
||||||
|
quotationId?: string;
|
||||||
|
orderId?: string;
|
||||||
|
leadId?: string;
|
||||||
|
description?: string;
|
||||||
|
tags?: string[];
|
||||||
|
lostReason?: string;
|
||||||
|
wonDate?: string;
|
||||||
|
lostDate?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpportunityCreateInput {
|
||||||
|
name: string;
|
||||||
|
partnerId: string;
|
||||||
|
stageId?: string;
|
||||||
|
userId?: string;
|
||||||
|
teamId?: string;
|
||||||
|
priority?: number;
|
||||||
|
probability?: number;
|
||||||
|
expectedRevenue?: number;
|
||||||
|
expectedCloseDate?: string;
|
||||||
|
leadId?: string;
|
||||||
|
description?: string;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpportunityUpdateInput extends Partial<OpportunityCreateInput> {
|
||||||
|
status?: OpportunityStatus;
|
||||||
|
lostReason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpportunityFilters {
|
||||||
|
companyId?: string;
|
||||||
|
status?: OpportunityStatus;
|
||||||
|
stageId?: string;
|
||||||
|
userId?: string;
|
||||||
|
partnerId?: string;
|
||||||
|
priority?: number;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Stage Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type StageType = 'lead' | 'opportunity';
|
||||||
|
|
||||||
|
export interface Stage {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
companyId?: string;
|
||||||
|
name: string;
|
||||||
|
type: StageType;
|
||||||
|
sequence: number;
|
||||||
|
probability?: number;
|
||||||
|
isFolded?: boolean;
|
||||||
|
isWon?: boolean;
|
||||||
|
requirements?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StageCreateInput {
|
||||||
|
name: string;
|
||||||
|
type: StageType;
|
||||||
|
sequence?: number;
|
||||||
|
probability?: number;
|
||||||
|
isFolded?: boolean;
|
||||||
|
isWon?: boolean;
|
||||||
|
requirements?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StageUpdateInput extends Partial<StageCreateInput> {}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Activity Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type ActivityType = 'call' | 'meeting' | 'email' | 'task' | 'note';
|
||||||
|
export type ActivityState = 'planned' | 'done' | 'cancelled';
|
||||||
|
|
||||||
|
export interface Activity {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
companyId: string;
|
||||||
|
type: ActivityType;
|
||||||
|
summary: string;
|
||||||
|
description?: string;
|
||||||
|
dateDeadline?: string;
|
||||||
|
dateDone?: string;
|
||||||
|
state: ActivityState;
|
||||||
|
userId?: string;
|
||||||
|
userName?: string;
|
||||||
|
leadId?: string;
|
||||||
|
opportunityId?: string;
|
||||||
|
partnerId?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityCreateInput {
|
||||||
|
type: ActivityType;
|
||||||
|
summary: string;
|
||||||
|
description?: string;
|
||||||
|
dateDeadline?: string;
|
||||||
|
leadId?: string;
|
||||||
|
opportunityId?: string;
|
||||||
|
partnerId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityUpdateInput extends Partial<ActivityCreateInput> {
|
||||||
|
state?: ActivityState;
|
||||||
|
dateDone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityFilters {
|
||||||
|
companyId?: string;
|
||||||
|
type?: ActivityType;
|
||||||
|
state?: ActivityState;
|
||||||
|
userId?: string;
|
||||||
|
leadId?: string;
|
||||||
|
opportunityId?: string;
|
||||||
|
partnerId?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Pipeline Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface PipelineStage {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
sequence: number;
|
||||||
|
probability: number;
|
||||||
|
count: number;
|
||||||
|
totalRevenue: number;
|
||||||
|
opportunities: Opportunity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Pipeline {
|
||||||
|
stages: PipelineStage[];
|
||||||
|
totals: {
|
||||||
|
totalOpportunities: number;
|
||||||
|
totalRevenue: number;
|
||||||
|
weightedRevenue: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Response Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface LeadsResponse {
|
||||||
|
data: Lead[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpportunitiesResponse {
|
||||||
|
data: Opportunity[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StagesResponse {
|
||||||
|
data: Stage[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivitiesResponse {
|
||||||
|
data: Activity[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
1
src/features/crm/types/index.ts
Normal file
1
src/features/crm/types/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './crm.types';
|
||||||
1
src/features/projects/api/index.ts
Normal file
1
src/features/projects/api/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './projects.api';
|
||||||
245
src/features/projects/api/projects.api.ts
Normal file
245
src/features/projects/api/projects.api.ts
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
import { api } from '@services/api/axios-instance';
|
||||||
|
import type {
|
||||||
|
Project,
|
||||||
|
ProjectCreateInput,
|
||||||
|
ProjectUpdateInput,
|
||||||
|
ProjectFilters,
|
||||||
|
ProjectsResponse,
|
||||||
|
ProjectStats,
|
||||||
|
Task,
|
||||||
|
TaskCreateInput,
|
||||||
|
TaskUpdateInput,
|
||||||
|
TaskFilters,
|
||||||
|
TasksResponse,
|
||||||
|
TaskBoard,
|
||||||
|
TaskStage,
|
||||||
|
TaskStageCreateInput,
|
||||||
|
TaskStageUpdateInput,
|
||||||
|
TaskStagesResponse,
|
||||||
|
Timesheet,
|
||||||
|
TimesheetCreateInput,
|
||||||
|
TimesheetUpdateInput,
|
||||||
|
TimesheetFilters,
|
||||||
|
TimesheetsResponse,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const PROJECTS_BASE = '/api/projects';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Projects API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const projectsApi = {
|
||||||
|
getAll: async (filters: ProjectFilters = {}): Promise<ProjectsResponse> => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters.companyId) params.append('company_id', filters.companyId);
|
||||||
|
if (filters.managerId) params.append('manager_id', filters.managerId);
|
||||||
|
if (filters.partnerId) params.append('partner_id', filters.partnerId);
|
||||||
|
if (filters.status) params.append('status', filters.status);
|
||||||
|
if (filters.search) params.append('search', filters.search);
|
||||||
|
if (filters.page) params.append('page', String(filters.page));
|
||||||
|
if (filters.limit) params.append('limit', String(filters.limit));
|
||||||
|
|
||||||
|
const response = await api.get<ProjectsResponse>(`${PROJECTS_BASE}?${params}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: async (id: string): Promise<Project> => {
|
||||||
|
const response = await api.get<Project>(`${PROJECTS_BASE}/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (data: ProjectCreateInput): Promise<Project> => {
|
||||||
|
const response = await api.post<Project>(PROJECTS_BASE, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: string, data: ProjectUpdateInput): Promise<Project> => {
|
||||||
|
const response = await api.patch<Project>(`${PROJECTS_BASE}/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: string): Promise<void> => {
|
||||||
|
await api.delete(`${PROJECTS_BASE}/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getStats: async (id: string): Promise<ProjectStats> => {
|
||||||
|
const response = await api.get<ProjectStats>(`${PROJECTS_BASE}/${id}/stats`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
activate: async (id: string): Promise<Project> => {
|
||||||
|
const response = await api.post<Project>(`${PROJECTS_BASE}/${id}/activate`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
complete: async (id: string): Promise<Project> => {
|
||||||
|
const response = await api.post<Project>(`${PROJECTS_BASE}/${id}/complete`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
cancel: async (id: string): Promise<Project> => {
|
||||||
|
const response = await api.post<Project>(`${PROJECTS_BASE}/${id}/cancel`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
hold: async (id: string): Promise<Project> => {
|
||||||
|
const response = await api.post<Project>(`${PROJECTS_BASE}/${id}/hold`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tasks API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const tasksApi = {
|
||||||
|
getAll: async (filters: TaskFilters = {}): Promise<TasksResponse> => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters.projectId) params.append('project_id', filters.projectId);
|
||||||
|
if (filters.assignedTo) params.append('assigned_to', filters.assignedTo);
|
||||||
|
if (filters.status) params.append('status', filters.status);
|
||||||
|
if (filters.priority) params.append('priority', filters.priority);
|
||||||
|
if (filters.parentId) params.append('parent_id', filters.parentId);
|
||||||
|
if (filters.stageId) params.append('stage_id', filters.stageId);
|
||||||
|
if (filters.search) params.append('search', filters.search);
|
||||||
|
if (filters.page) params.append('page', String(filters.page));
|
||||||
|
if (filters.limit) params.append('limit', String(filters.limit));
|
||||||
|
|
||||||
|
const response = await api.get<TasksResponse>(`${PROJECTS_BASE}/tasks?${params}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: async (id: string): Promise<Task> => {
|
||||||
|
const response = await api.get<Task>(`${PROJECTS_BASE}/tasks/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (data: TaskCreateInput): Promise<Task> => {
|
||||||
|
const response = await api.post<Task>(`${PROJECTS_BASE}/tasks`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: string, data: TaskUpdateInput): Promise<Task> => {
|
||||||
|
const response = await api.patch<Task>(`${PROJECTS_BASE}/tasks/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: string): Promise<void> => {
|
||||||
|
await api.delete(`${PROJECTS_BASE}/tasks/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getBoard: async (projectId: string): Promise<TaskBoard> => {
|
||||||
|
const response = await api.get<TaskBoard>(`${PROJECTS_BASE}/${projectId}/board`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
moveToStage: async (taskId: string, stageId: string, sequence?: number): Promise<Task> => {
|
||||||
|
const response = await api.post<Task>(`${PROJECTS_BASE}/tasks/${taskId}/move`, {
|
||||||
|
stageId,
|
||||||
|
sequence,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
start: async (id: string): Promise<Task> => {
|
||||||
|
const response = await api.post<Task>(`${PROJECTS_BASE}/tasks/${id}/start`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
complete: async (id: string): Promise<Task> => {
|
||||||
|
const response = await api.post<Task>(`${PROJECTS_BASE}/tasks/${id}/complete`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
cancel: async (id: string): Promise<Task> => {
|
||||||
|
const response = await api.post<Task>(`${PROJECTS_BASE}/tasks/${id}/cancel`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Task Stages API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const taskStagesApi = {
|
||||||
|
getAll: async (projectId?: string): Promise<TaskStagesResponse> => {
|
||||||
|
const params = projectId ? `?project_id=${projectId}` : '';
|
||||||
|
const response = await api.get<TaskStagesResponse>(`${PROJECTS_BASE}/stages${params}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: async (id: string): Promise<TaskStage> => {
|
||||||
|
const response = await api.get<TaskStage>(`${PROJECTS_BASE}/stages/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (data: TaskStageCreateInput): Promise<TaskStage> => {
|
||||||
|
const response = await api.post<TaskStage>(`${PROJECTS_BASE}/stages`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: string, data: TaskStageUpdateInput): Promise<TaskStage> => {
|
||||||
|
const response = await api.patch<TaskStage>(`${PROJECTS_BASE}/stages/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: string): Promise<void> => {
|
||||||
|
await api.delete(`${PROJECTS_BASE}/stages/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
reorder: async (stageIds: string[]): Promise<void> => {
|
||||||
|
await api.post(`${PROJECTS_BASE}/stages/reorder`, { stageIds });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Timesheets API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const timesheetsApi = {
|
||||||
|
getAll: async (filters: TimesheetFilters = {}): Promise<TimesheetsResponse> => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters.companyId) params.append('company_id', filters.companyId);
|
||||||
|
if (filters.projectId) params.append('project_id', filters.projectId);
|
||||||
|
if (filters.taskId) params.append('task_id', filters.taskId);
|
||||||
|
if (filters.employeeId) params.append('employee_id', filters.employeeId);
|
||||||
|
if (filters.dateFrom) params.append('date_from', filters.dateFrom);
|
||||||
|
if (filters.dateTo) params.append('date_to', filters.dateTo);
|
||||||
|
if (filters.isBillable !== undefined) params.append('is_billable', String(filters.isBillable));
|
||||||
|
if (filters.isBilled !== undefined) params.append('is_billed', String(filters.isBilled));
|
||||||
|
if (filters.search) params.append('search', filters.search);
|
||||||
|
if (filters.page) params.append('page', String(filters.page));
|
||||||
|
if (filters.limit) params.append('limit', String(filters.limit));
|
||||||
|
|
||||||
|
const response = await api.get<TimesheetsResponse>(`${PROJECTS_BASE}/timesheets?${params}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: async (id: string): Promise<Timesheet> => {
|
||||||
|
const response = await api.get<Timesheet>(`${PROJECTS_BASE}/timesheets/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (data: TimesheetCreateInput): Promise<Timesheet> => {
|
||||||
|
const response = await api.post<Timesheet>(`${PROJECTS_BASE}/timesheets`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: string, data: TimesheetUpdateInput): Promise<Timesheet> => {
|
||||||
|
const response = await api.patch<Timesheet>(`${PROJECTS_BASE}/timesheets/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: string): Promise<void> => {
|
||||||
|
await api.delete(`${PROJECTS_BASE}/timesheets/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getByProject: async (projectId: string, filters: Omit<TimesheetFilters, 'projectId'> = {}): Promise<TimesheetsResponse> => {
|
||||||
|
return timesheetsApi.getAll({ ...filters, projectId });
|
||||||
|
},
|
||||||
|
|
||||||
|
getByEmployee: async (employeeId: string, filters: Omit<TimesheetFilters, 'employeeId'> = {}): Promise<TimesheetsResponse> => {
|
||||||
|
return timesheetsApi.getAll({ ...filters, employeeId });
|
||||||
|
},
|
||||||
|
};
|
||||||
1
src/features/projects/hooks/index.ts
Normal file
1
src/features/projects/hooks/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './useProjects';
|
||||||
499
src/features/projects/hooks/useProjects.ts
Normal file
499
src/features/projects/hooks/useProjects.ts
Normal file
@ -0,0 +1,499 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { projectsApi, tasksApi, taskStagesApi, timesheetsApi } from '../api';
|
||||||
|
import type {
|
||||||
|
Project,
|
||||||
|
ProjectCreateInput,
|
||||||
|
ProjectUpdateInput,
|
||||||
|
ProjectFilters,
|
||||||
|
ProjectStatus,
|
||||||
|
ProjectStats,
|
||||||
|
Task,
|
||||||
|
TaskCreateInput,
|
||||||
|
TaskUpdateInput,
|
||||||
|
TaskFilters,
|
||||||
|
TaskStatus,
|
||||||
|
TaskPriority,
|
||||||
|
TaskBoard,
|
||||||
|
TaskStage,
|
||||||
|
TaskStageCreateInput,
|
||||||
|
TaskStageUpdateInput,
|
||||||
|
Timesheet,
|
||||||
|
TimesheetCreateInput,
|
||||||
|
TimesheetUpdateInput,
|
||||||
|
TimesheetFilters,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// useProjects Hook
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface UseProjectsOptions {
|
||||||
|
managerId?: string;
|
||||||
|
partnerId?: string;
|
||||||
|
status?: ProjectStatus;
|
||||||
|
search?: string;
|
||||||
|
limit?: number;
|
||||||
|
autoFetch?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProjects(options: UseProjectsOptions = {}) {
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const { managerId, partnerId, status, search, limit = 20, autoFetch = true } = options;
|
||||||
|
|
||||||
|
const fetchProjects = useCallback(async (pageNum = 1) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const filters: ProjectFilters = {
|
||||||
|
managerId,
|
||||||
|
partnerId,
|
||||||
|
status,
|
||||||
|
search,
|
||||||
|
page: pageNum,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
const response = await projectsApi.getAll(filters);
|
||||||
|
setProjects(response.data);
|
||||||
|
setTotal(response.total);
|
||||||
|
setPage(response.page);
|
||||||
|
setTotalPages(response.totalPages);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error('Error fetching projects'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [managerId, partnerId, status, search, limit]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoFetch) {
|
||||||
|
fetchProjects(1);
|
||||||
|
}
|
||||||
|
}, [fetchProjects, autoFetch]);
|
||||||
|
|
||||||
|
const createProject = useCallback(async (data: ProjectCreateInput): Promise<Project> => {
|
||||||
|
const project = await projectsApi.create(data);
|
||||||
|
await fetchProjects(page);
|
||||||
|
return project;
|
||||||
|
}, [fetchProjects, page]);
|
||||||
|
|
||||||
|
const updateProject = useCallback(async (id: string, data: ProjectUpdateInput): Promise<Project> => {
|
||||||
|
const project = await projectsApi.update(id, data);
|
||||||
|
await fetchProjects(page);
|
||||||
|
return project;
|
||||||
|
}, [fetchProjects, page]);
|
||||||
|
|
||||||
|
const deleteProject = useCallback(async (id: string): Promise<void> => {
|
||||||
|
await projectsApi.delete(id);
|
||||||
|
await fetchProjects(page);
|
||||||
|
}, [fetchProjects, page]);
|
||||||
|
|
||||||
|
const activateProject = useCallback(async (id: string): Promise<Project> => {
|
||||||
|
const project = await projectsApi.activate(id);
|
||||||
|
await fetchProjects(page);
|
||||||
|
return project;
|
||||||
|
}, [fetchProjects, page]);
|
||||||
|
|
||||||
|
const completeProject = useCallback(async (id: string): Promise<Project> => {
|
||||||
|
const project = await projectsApi.complete(id);
|
||||||
|
await fetchProjects(page);
|
||||||
|
return project;
|
||||||
|
}, [fetchProjects, page]);
|
||||||
|
|
||||||
|
const cancelProject = useCallback(async (id: string): Promise<Project> => {
|
||||||
|
const project = await projectsApi.cancel(id);
|
||||||
|
await fetchProjects(page);
|
||||||
|
return project;
|
||||||
|
}, [fetchProjects, page]);
|
||||||
|
|
||||||
|
const holdProject = useCallback(async (id: string): Promise<Project> => {
|
||||||
|
const project = await projectsApi.hold(id);
|
||||||
|
await fetchProjects(page);
|
||||||
|
return project;
|
||||||
|
}, [fetchProjects, page]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
projects,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
setPage: (p: number) => fetchProjects(p),
|
||||||
|
refresh: () => fetchProjects(page),
|
||||||
|
createProject,
|
||||||
|
updateProject,
|
||||||
|
deleteProject,
|
||||||
|
activateProject,
|
||||||
|
completeProject,
|
||||||
|
cancelProject,
|
||||||
|
holdProject,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// useProject Hook (Single Project)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export function useProject(projectId: string | null) {
|
||||||
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
|
const [stats, setStats] = useState<ProjectStats | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const fetchProject = useCallback(async () => {
|
||||||
|
if (!projectId) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [projectData, statsData] = await Promise.all([
|
||||||
|
projectsApi.getById(projectId),
|
||||||
|
projectsApi.getStats(projectId),
|
||||||
|
]);
|
||||||
|
setProject(projectData);
|
||||||
|
setStats(statsData);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error('Error fetching project'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProject();
|
||||||
|
}, [fetchProject]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
project,
|
||||||
|
stats,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refresh: fetchProject,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// useTasks Hook
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface UseTasksOptions {
|
||||||
|
projectId?: string;
|
||||||
|
assignedTo?: string;
|
||||||
|
status?: TaskStatus;
|
||||||
|
priority?: TaskPriority;
|
||||||
|
parentId?: string;
|
||||||
|
stageId?: string;
|
||||||
|
search?: string;
|
||||||
|
limit?: number;
|
||||||
|
autoFetch?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTasks(options: UseTasksOptions = {}) {
|
||||||
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const { projectId, assignedTo, status, priority, parentId, stageId, search, limit = 20, autoFetch = true } = options;
|
||||||
|
|
||||||
|
const fetchTasks = useCallback(async (pageNum = 1) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const filters: TaskFilters = {
|
||||||
|
projectId,
|
||||||
|
assignedTo,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
parentId,
|
||||||
|
stageId,
|
||||||
|
search,
|
||||||
|
page: pageNum,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
const response = await tasksApi.getAll(filters);
|
||||||
|
setTasks(response.data);
|
||||||
|
setTotal(response.total);
|
||||||
|
setPage(response.page);
|
||||||
|
setTotalPages(response.totalPages);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error('Error fetching tasks'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [projectId, assignedTo, status, priority, parentId, stageId, search, limit]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoFetch) {
|
||||||
|
fetchTasks(1);
|
||||||
|
}
|
||||||
|
}, [fetchTasks, autoFetch]);
|
||||||
|
|
||||||
|
const createTask = useCallback(async (data: TaskCreateInput): Promise<Task> => {
|
||||||
|
const task = await tasksApi.create(data);
|
||||||
|
await fetchTasks(page);
|
||||||
|
return task;
|
||||||
|
}, [fetchTasks, page]);
|
||||||
|
|
||||||
|
const updateTask = useCallback(async (id: string, data: TaskUpdateInput): Promise<Task> => {
|
||||||
|
const task = await tasksApi.update(id, data);
|
||||||
|
await fetchTasks(page);
|
||||||
|
return task;
|
||||||
|
}, [fetchTasks, page]);
|
||||||
|
|
||||||
|
const deleteTask = useCallback(async (id: string): Promise<void> => {
|
||||||
|
await tasksApi.delete(id);
|
||||||
|
await fetchTasks(page);
|
||||||
|
}, [fetchTasks, page]);
|
||||||
|
|
||||||
|
const startTask = useCallback(async (id: string): Promise<Task> => {
|
||||||
|
const task = await tasksApi.start(id);
|
||||||
|
await fetchTasks(page);
|
||||||
|
return task;
|
||||||
|
}, [fetchTasks, page]);
|
||||||
|
|
||||||
|
const completeTask = useCallback(async (id: string): Promise<Task> => {
|
||||||
|
const task = await tasksApi.complete(id);
|
||||||
|
await fetchTasks(page);
|
||||||
|
return task;
|
||||||
|
}, [fetchTasks, page]);
|
||||||
|
|
||||||
|
const cancelTask = useCallback(async (id: string): Promise<Task> => {
|
||||||
|
const task = await tasksApi.cancel(id);
|
||||||
|
await fetchTasks(page);
|
||||||
|
return task;
|
||||||
|
}, [fetchTasks, page]);
|
||||||
|
|
||||||
|
const moveTask = useCallback(async (taskId: string, stageId: string, sequence?: number): Promise<Task> => {
|
||||||
|
const task = await tasksApi.moveToStage(taskId, stageId, sequence);
|
||||||
|
await fetchTasks(page);
|
||||||
|
return task;
|
||||||
|
}, [fetchTasks, page]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tasks,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
setPage: (p: number) => fetchTasks(p),
|
||||||
|
refresh: () => fetchTasks(page),
|
||||||
|
createTask,
|
||||||
|
updateTask,
|
||||||
|
deleteTask,
|
||||||
|
startTask,
|
||||||
|
completeTask,
|
||||||
|
cancelTask,
|
||||||
|
moveTask,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// useTaskBoard Hook
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export function useTaskBoard(projectId: string | null) {
|
||||||
|
const [board, setBoard] = useState<TaskBoard | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const fetchBoard = useCallback(async () => {
|
||||||
|
if (!projectId) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await tasksApi.getBoard(projectId);
|
||||||
|
setBoard(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error('Error fetching task board'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchBoard();
|
||||||
|
}, [fetchBoard]);
|
||||||
|
|
||||||
|
const moveTask = useCallback(async (taskId: string, stageId: string, sequence?: number): Promise<void> => {
|
||||||
|
await tasksApi.moveToStage(taskId, stageId, sequence);
|
||||||
|
await fetchBoard();
|
||||||
|
}, [fetchBoard]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
board,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refresh: fetchBoard,
|
||||||
|
moveTask,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// useTaskStages Hook
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export function useTaskStages(projectId?: string) {
|
||||||
|
const [stages, setStages] = useState<TaskStage[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const fetchStages = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await taskStagesApi.getAll(projectId);
|
||||||
|
setStages(response.data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error('Error fetching task stages'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStages();
|
||||||
|
}, [fetchStages]);
|
||||||
|
|
||||||
|
const createStage = useCallback(async (data: TaskStageCreateInput): Promise<TaskStage> => {
|
||||||
|
const stage = await taskStagesApi.create(data);
|
||||||
|
await fetchStages();
|
||||||
|
return stage;
|
||||||
|
}, [fetchStages]);
|
||||||
|
|
||||||
|
const updateStage = useCallback(async (id: string, data: TaskStageUpdateInput): Promise<TaskStage> => {
|
||||||
|
const stage = await taskStagesApi.update(id, data);
|
||||||
|
await fetchStages();
|
||||||
|
return stage;
|
||||||
|
}, [fetchStages]);
|
||||||
|
|
||||||
|
const deleteStage = useCallback(async (id: string): Promise<void> => {
|
||||||
|
await taskStagesApi.delete(id);
|
||||||
|
await fetchStages();
|
||||||
|
}, [fetchStages]);
|
||||||
|
|
||||||
|
const reorderStages = useCallback(async (stageIds: string[]): Promise<void> => {
|
||||||
|
await taskStagesApi.reorder(stageIds);
|
||||||
|
await fetchStages();
|
||||||
|
}, [fetchStages]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
stages,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refresh: fetchStages,
|
||||||
|
createStage,
|
||||||
|
updateStage,
|
||||||
|
deleteStage,
|
||||||
|
reorderStages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// useTimesheets Hook
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface UseTimesheetsOptions {
|
||||||
|
projectId?: string;
|
||||||
|
taskId?: string;
|
||||||
|
employeeId?: string;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
isBillable?: boolean;
|
||||||
|
isBilled?: boolean;
|
||||||
|
search?: string;
|
||||||
|
limit?: number;
|
||||||
|
autoFetch?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTimesheets(options: UseTimesheetsOptions = {}) {
|
||||||
|
const [timesheets, setTimesheets] = useState<Timesheet[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const { projectId, taskId, employeeId, dateFrom, dateTo, isBillable, isBilled, search, limit = 20, autoFetch = true } = options;
|
||||||
|
|
||||||
|
const fetchTimesheets = useCallback(async (pageNum = 1) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const filters: TimesheetFilters = {
|
||||||
|
projectId,
|
||||||
|
taskId,
|
||||||
|
employeeId,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
isBillable,
|
||||||
|
isBilled,
|
||||||
|
search,
|
||||||
|
page: pageNum,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
const response = await timesheetsApi.getAll(filters);
|
||||||
|
setTimesheets(response.data);
|
||||||
|
setTotal(response.total);
|
||||||
|
setPage(response.page);
|
||||||
|
setTotalPages(response.totalPages);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error('Error fetching timesheets'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [projectId, taskId, employeeId, dateFrom, dateTo, isBillable, isBilled, search, limit]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoFetch) {
|
||||||
|
fetchTimesheets(1);
|
||||||
|
}
|
||||||
|
}, [fetchTimesheets, autoFetch]);
|
||||||
|
|
||||||
|
const createTimesheet = useCallback(async (data: TimesheetCreateInput): Promise<Timesheet> => {
|
||||||
|
const timesheet = await timesheetsApi.create(data);
|
||||||
|
await fetchTimesheets(page);
|
||||||
|
return timesheet;
|
||||||
|
}, [fetchTimesheets, page]);
|
||||||
|
|
||||||
|
const updateTimesheet = useCallback(async (id: string, data: TimesheetUpdateInput): Promise<Timesheet> => {
|
||||||
|
const timesheet = await timesheetsApi.update(id, data);
|
||||||
|
await fetchTimesheets(page);
|
||||||
|
return timesheet;
|
||||||
|
}, [fetchTimesheets, page]);
|
||||||
|
|
||||||
|
const deleteTimesheet = useCallback(async (id: string): Promise<void> => {
|
||||||
|
await timesheetsApi.delete(id);
|
||||||
|
await fetchTimesheets(page);
|
||||||
|
}, [fetchTimesheets, page]);
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
const totalHours = timesheets.reduce((sum, t) => sum + t.hours, 0);
|
||||||
|
const billableHours = timesheets.filter(t => t.isBillable).reduce((sum, t) => sum + t.hours, 0);
|
||||||
|
const billedHours = timesheets.filter(t => t.isBilled).reduce((sum, t) => sum + t.hours, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
timesheets,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
totalHours,
|
||||||
|
billableHours,
|
||||||
|
billedHours,
|
||||||
|
setPage: (p: number) => fetchTimesheets(p),
|
||||||
|
refresh: () => fetchTimesheets(page),
|
||||||
|
createTimesheet,
|
||||||
|
updateTimesheet,
|
||||||
|
deleteTimesheet,
|
||||||
|
};
|
||||||
|
}
|
||||||
3
src/features/projects/index.ts
Normal file
3
src/features/projects/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './api/projects.api';
|
||||||
|
export * from './types';
|
||||||
|
export * from './hooks';
|
||||||
1
src/features/projects/types/index.ts
Normal file
1
src/features/projects/types/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './projects.types';
|
||||||
262
src/features/projects/types/projects.types.ts
Normal file
262
src/features/projects/types/projects.types.ts
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
// Projects Types - Projects, Tasks, Timesheets
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Project Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type ProjectStatus = 'draft' | 'active' | 'completed' | 'cancelled' | 'on_hold';
|
||||||
|
export type ProjectPrivacy = 'public' | 'private' | 'followers';
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
companyId: string;
|
||||||
|
name: string;
|
||||||
|
code?: string;
|
||||||
|
description?: string;
|
||||||
|
managerId?: string;
|
||||||
|
managerName?: string;
|
||||||
|
partnerId?: string;
|
||||||
|
partnerName?: string;
|
||||||
|
status: ProjectStatus;
|
||||||
|
privacy: ProjectPrivacy;
|
||||||
|
dateStart?: string;
|
||||||
|
dateEnd?: string;
|
||||||
|
plannedHours?: number;
|
||||||
|
allowTimesheets: boolean;
|
||||||
|
taskCount?: number;
|
||||||
|
completedTaskCount?: number;
|
||||||
|
totalHours?: number;
|
||||||
|
tags?: string[];
|
||||||
|
color?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectCreateInput {
|
||||||
|
name: string;
|
||||||
|
code?: string;
|
||||||
|
description?: string;
|
||||||
|
managerId?: string;
|
||||||
|
partnerId?: string;
|
||||||
|
status?: ProjectStatus;
|
||||||
|
privacy?: ProjectPrivacy;
|
||||||
|
dateStart?: string;
|
||||||
|
dateEnd?: string;
|
||||||
|
plannedHours?: number;
|
||||||
|
allowTimesheets?: boolean;
|
||||||
|
tags?: string[];
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectUpdateInput extends Partial<ProjectCreateInput> {}
|
||||||
|
|
||||||
|
export interface ProjectFilters {
|
||||||
|
companyId?: string;
|
||||||
|
managerId?: string;
|
||||||
|
partnerId?: string;
|
||||||
|
status?: ProjectStatus;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Task Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type TaskStatus = 'todo' | 'in_progress' | 'review' | 'done' | 'cancelled';
|
||||||
|
export type TaskPriority = 'low' | 'normal' | 'high' | 'urgent';
|
||||||
|
|
||||||
|
export interface Task {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
projectId: string;
|
||||||
|
projectName?: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
assignedTo?: string;
|
||||||
|
assignedToName?: string;
|
||||||
|
parentId?: string;
|
||||||
|
parentName?: string;
|
||||||
|
priority: TaskPriority;
|
||||||
|
status: TaskStatus;
|
||||||
|
dateDeadline?: string;
|
||||||
|
dateStart?: string;
|
||||||
|
dateEnd?: string;
|
||||||
|
estimatedHours?: number;
|
||||||
|
spentHours?: number;
|
||||||
|
remainingHours?: number;
|
||||||
|
sequence: number;
|
||||||
|
stageId?: string;
|
||||||
|
stageName?: string;
|
||||||
|
tags?: string[];
|
||||||
|
subtaskCount?: number;
|
||||||
|
completedSubtaskCount?: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskCreateInput {
|
||||||
|
projectId: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
assignedTo?: string;
|
||||||
|
parentId?: string;
|
||||||
|
priority?: TaskPriority;
|
||||||
|
status?: TaskStatus;
|
||||||
|
dateDeadline?: string;
|
||||||
|
dateStart?: string;
|
||||||
|
estimatedHours?: number;
|
||||||
|
sequence?: number;
|
||||||
|
stageId?: string;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskUpdateInput extends Partial<Omit<TaskCreateInput, 'projectId'>> {}
|
||||||
|
|
||||||
|
export interface TaskFilters {
|
||||||
|
projectId?: string;
|
||||||
|
assignedTo?: string;
|
||||||
|
status?: TaskStatus;
|
||||||
|
priority?: TaskPriority;
|
||||||
|
parentId?: string;
|
||||||
|
stageId?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Timesheet Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface Timesheet {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
companyId: string;
|
||||||
|
projectId: string;
|
||||||
|
projectName?: string;
|
||||||
|
taskId?: string;
|
||||||
|
taskName?: string;
|
||||||
|
employeeId: string;
|
||||||
|
employeeName?: string;
|
||||||
|
date: string;
|
||||||
|
hours: number;
|
||||||
|
description?: string;
|
||||||
|
isBillable: boolean;
|
||||||
|
isBilled?: boolean;
|
||||||
|
invoiceLineId?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimesheetCreateInput {
|
||||||
|
projectId: string;
|
||||||
|
taskId?: string;
|
||||||
|
employeeId: string;
|
||||||
|
date: string;
|
||||||
|
hours: number;
|
||||||
|
description?: string;
|
||||||
|
isBillable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimesheetUpdateInput extends Partial<Omit<TimesheetCreateInput, 'projectId' | 'employeeId'>> {}
|
||||||
|
|
||||||
|
export interface TimesheetFilters {
|
||||||
|
companyId?: string;
|
||||||
|
projectId?: string;
|
||||||
|
taskId?: string;
|
||||||
|
employeeId?: string;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
isBillable?: boolean;
|
||||||
|
isBilled?: boolean;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Task Stage Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface TaskStage {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
projectId?: string;
|
||||||
|
name: string;
|
||||||
|
sequence: number;
|
||||||
|
isFolded?: boolean;
|
||||||
|
description?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskStageCreateInput {
|
||||||
|
projectId?: string;
|
||||||
|
name: string;
|
||||||
|
sequence?: number;
|
||||||
|
isFolded?: boolean;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskStageUpdateInput extends Partial<TaskStageCreateInput> {}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Project Stats Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ProjectStats {
|
||||||
|
projectId: string;
|
||||||
|
totalTasks: number;
|
||||||
|
completedTasks: number;
|
||||||
|
inProgressTasks: number;
|
||||||
|
overdueTasks: number;
|
||||||
|
totalHours: number;
|
||||||
|
billableHours: number;
|
||||||
|
billedHours: number;
|
||||||
|
completionPercentage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskBoard {
|
||||||
|
stages: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
sequence: number;
|
||||||
|
tasks: Task[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Response Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ProjectsResponse {
|
||||||
|
data: Project[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TasksResponse {
|
||||||
|
data: Task[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimesheetsResponse {
|
||||||
|
data: Timesheet[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskStagesResponse {
|
||||||
|
data: TaskStage[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
442
src/pages/crm/LeadsPage.tsx
Normal file
442
src/pages/crm/LeadsPage.tsx
Normal file
@ -0,0 +1,442 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Plus,
|
||||||
|
MoreVertical,
|
||||||
|
Eye,
|
||||||
|
UserCheck,
|
||||||
|
XCircle,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
Star,
|
||||||
|
Phone,
|
||||||
|
Mail,
|
||||||
|
TrendingUp,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@components/atoms/Button';
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||||
|
import { DataTable, type Column } from '@components/organisms/DataTable';
|
||||||
|
import { Dropdown, type DropdownItem } from '@components/organisms/Dropdown';
|
||||||
|
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||||
|
import { ConfirmModal } from '@components/organisms/Modal';
|
||||||
|
import { NoDataEmptyState, ErrorEmptyState } from '@components/templates/EmptyState';
|
||||||
|
import { useLeads } from '@features/crm/hooks';
|
||||||
|
import type { Lead, LeadStatus, LeadSource } from '@features/crm/types';
|
||||||
|
import { formatNumber } from '@utils/formatters';
|
||||||
|
|
||||||
|
const statusLabels: Record<LeadStatus, string> = {
|
||||||
|
new: 'Nuevo',
|
||||||
|
contacted: 'Contactado',
|
||||||
|
qualified: 'Calificado',
|
||||||
|
converted: 'Convertido',
|
||||||
|
lost: 'Perdido',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColors: Record<LeadStatus, string> = {
|
||||||
|
new: 'bg-blue-100 text-blue-700',
|
||||||
|
contacted: 'bg-yellow-100 text-yellow-700',
|
||||||
|
qualified: 'bg-green-100 text-green-700',
|
||||||
|
converted: 'bg-purple-100 text-purple-700',
|
||||||
|
lost: 'bg-red-100 text-red-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sourceLabels: Record<LeadSource, string> = {
|
||||||
|
website: 'Sitio Web',
|
||||||
|
phone: 'Telefono',
|
||||||
|
email: 'Email',
|
||||||
|
referral: 'Referido',
|
||||||
|
social_media: 'Redes Sociales',
|
||||||
|
advertising: 'Publicidad',
|
||||||
|
event: 'Evento',
|
||||||
|
other: 'Otro',
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (value: number): string => {
|
||||||
|
return formatNumber(value, 'es-MX', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LeadsPage() {
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState<LeadStatus | ''>('');
|
||||||
|
const [selectedSource, setSelectedSource] = useState<LeadSource | ''>('');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [leadToConvert, setLeadToConvert] = useState<Lead | null>(null);
|
||||||
|
const [leadToLose, setLeadToLose] = useState<Lead | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
leads,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
setPage,
|
||||||
|
refresh,
|
||||||
|
convertLead,
|
||||||
|
markLeadLost,
|
||||||
|
} = useLeads({
|
||||||
|
status: selectedStatus || undefined,
|
||||||
|
source: selectedSource || undefined,
|
||||||
|
search: searchTerm || undefined,
|
||||||
|
limit: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getActionsMenu = (lead: Lead): DropdownItem[] => {
|
||||||
|
const items: DropdownItem[] = [
|
||||||
|
{
|
||||||
|
key: 'view',
|
||||||
|
label: 'Ver detalle',
|
||||||
|
icon: <Eye className="h-4 w-4" />,
|
||||||
|
onClick: () => console.log('View', lead.id),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (lead.status !== 'converted' && lead.status !== 'lost') {
|
||||||
|
items.push({
|
||||||
|
key: 'convert',
|
||||||
|
label: 'Convertir a oportunidad',
|
||||||
|
icon: <UserCheck className="h-4 w-4" />,
|
||||||
|
onClick: () => setLeadToConvert(lead),
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
key: 'lose',
|
||||||
|
label: 'Marcar como perdido',
|
||||||
|
icon: <XCircle className="h-4 w-4" />,
|
||||||
|
danger: true,
|
||||||
|
onClick: () => setLeadToLose(lead),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: Column<Lead>[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'Lead',
|
||||||
|
render: (lead) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-50">
|
||||||
|
<Users className="h-5 w-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">{lead.name}</div>
|
||||||
|
{lead.contactName && (
|
||||||
|
<div className="text-sm text-gray-500">{lead.contactName}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'contact',
|
||||||
|
header: 'Contacto',
|
||||||
|
render: (lead) => (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{lead.email && (
|
||||||
|
<div className="flex items-center gap-1 text-sm text-gray-600">
|
||||||
|
<Mail className="h-3 w-3" />
|
||||||
|
{lead.email}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{lead.phone && (
|
||||||
|
<div className="flex items-center gap-1 text-sm text-gray-600">
|
||||||
|
<Phone className="h-3 w-3" />
|
||||||
|
{lead.phone}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'source',
|
||||||
|
header: 'Origen',
|
||||||
|
render: (lead) => (
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{lead.source ? sourceLabels[lead.source] : '-'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'probability',
|
||||||
|
header: 'Probabilidad',
|
||||||
|
render: (lead) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-2 w-16 rounded-full bg-gray-200">
|
||||||
|
<div
|
||||||
|
className="h-2 rounded-full bg-blue-500"
|
||||||
|
style={{ width: `${lead.probability}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-600">{lead.probability}%</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'expectedRevenue',
|
||||||
|
header: 'Ingreso Esperado',
|
||||||
|
render: (lead) => (
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="font-medium text-gray-900">
|
||||||
|
{lead.expectedRevenue ? `$${formatCurrency(lead.expectedRevenue)}` : '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'priority',
|
||||||
|
header: 'Prioridad',
|
||||||
|
render: (lead) => (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<Star
|
||||||
|
key={i}
|
||||||
|
className={`h-4 w-4 ${i < lead.priority ? 'fill-amber-400 text-amber-400' : 'text-gray-300'}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: 'Estado',
|
||||||
|
render: (lead) => (
|
||||||
|
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${statusColors[lead.status]}`}>
|
||||||
|
{statusLabels[lead.status]}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
render: (lead) => (
|
||||||
|
<Dropdown
|
||||||
|
trigger={
|
||||||
|
<button className="rounded p-1 hover:bg-gray-100">
|
||||||
|
<MoreVertical className="h-4 w-4 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
items={getActionsMenu(lead)}
|
||||||
|
align="right"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleConvert = async () => {
|
||||||
|
if (leadToConvert) {
|
||||||
|
await convertLead(leadToConvert.id, { createOpportunity: true });
|
||||||
|
setLeadToConvert(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLose = async () => {
|
||||||
|
if (leadToLose) {
|
||||||
|
await markLeadLost(leadToLose.id, 'Perdido por el usuario');
|
||||||
|
setLeadToLose(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate summary stats
|
||||||
|
const newCount = leads.filter(l => l.status === 'new').length;
|
||||||
|
const qualifiedCount = leads.filter(l => l.status === 'qualified').length;
|
||||||
|
const convertedCount = leads.filter(l => l.status === 'converted').length;
|
||||||
|
const totalExpectedRevenue = leads.reduce((sum, l) => sum + (l.expectedRevenue || 0), 0);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<ErrorEmptyState onRetry={refresh} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
<Breadcrumbs items={[
|
||||||
|
{ label: 'CRM', href: '/crm' },
|
||||||
|
{ label: 'Leads' },
|
||||||
|
]} />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Leads</h1>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Gestiona prospectos y convierte leads en oportunidades
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={refresh} disabled={isLoading}>
|
||||||
|
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||||
|
Actualizar
|
||||||
|
</Button>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Nuevo lead
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Stats */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||||
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedStatus('new')}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
|
||||||
|
<Users className="h-5 w-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-500">Nuevos</div>
|
||||||
|
<div className="text-xl font-bold text-blue-600">{newCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedStatus('qualified')}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
|
||||||
|
<Star className="h-5 w-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-500">Calificados</div>
|
||||||
|
<div className="text-xl font-bold text-green-600">{qualifiedCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedStatus('converted')}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-100">
|
||||||
|
<UserCheck className="h-5 w-5 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-500">Convertidos</div>
|
||||||
|
<div className="text-xl font-bold text-purple-600">{convertedCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-100">
|
||||||
|
<TrendingUp className="h-5 w-5 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-500">Potencial Total</div>
|
||||||
|
<div className="text-xl font-bold text-amber-600">${formatCurrency(totalExpectedRevenue)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Lista de Leads</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<div className="relative flex-1 min-w-[200px]">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar leads..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-gray-300 py-2 pl-10 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={selectedStatus}
|
||||||
|
onChange={(e) => setSelectedStatus(e.target.value as LeadStatus | '')}
|
||||||
|
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Todos los estados</option>
|
||||||
|
{Object.entries(statusLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={selectedSource}
|
||||||
|
onChange={(e) => setSelectedSource(e.target.value as LeadSource | '')}
|
||||||
|
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Todos los origenes</option>
|
||||||
|
{Object.entries(sourceLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{(selectedStatus || selectedSource || searchTerm) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedStatus('');
|
||||||
|
setSelectedSource('');
|
||||||
|
setSearchTerm('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Limpiar filtros
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{leads.length === 0 && !isLoading ? (
|
||||||
|
<NoDataEmptyState
|
||||||
|
entityName="leads"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={leads}
|
||||||
|
columns={columns}
|
||||||
|
isLoading={isLoading}
|
||||||
|
pagination={{
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
total,
|
||||||
|
limit: 20,
|
||||||
|
onPageChange: setPage,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Convert Lead Modal */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={!!leadToConvert}
|
||||||
|
onClose={() => setLeadToConvert(null)}
|
||||||
|
onConfirm={handleConvert}
|
||||||
|
title="Convertir lead"
|
||||||
|
message={`¿Convertir "${leadToConvert?.name}" en una oportunidad de venta?`}
|
||||||
|
variant="success"
|
||||||
|
confirmText="Convertir"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Lose Lead Modal */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={!!leadToLose}
|
||||||
|
onClose={() => setLeadToLose(null)}
|
||||||
|
onConfirm={handleLose}
|
||||||
|
title="Marcar como perdido"
|
||||||
|
message={`¿Marcar el lead "${leadToLose?.name}" como perdido? Esta accion no se puede deshacer.`}
|
||||||
|
variant="danger"
|
||||||
|
confirmText="Marcar como perdido"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LeadsPage;
|
||||||
422
src/pages/crm/OpportunitiesPage.tsx
Normal file
422
src/pages/crm/OpportunitiesPage.tsx
Normal file
@ -0,0 +1,422 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Target,
|
||||||
|
Plus,
|
||||||
|
MoreVertical,
|
||||||
|
Eye,
|
||||||
|
Trophy,
|
||||||
|
XCircle,
|
||||||
|
Calendar,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
DollarSign,
|
||||||
|
TrendingUp,
|
||||||
|
Building,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@components/atoms/Button';
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||||
|
import { DataTable, type Column } from '@components/organisms/DataTable';
|
||||||
|
import { Dropdown, type DropdownItem } from '@components/organisms/Dropdown';
|
||||||
|
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||||
|
import { ConfirmModal } from '@components/organisms/Modal';
|
||||||
|
import { NoDataEmptyState, ErrorEmptyState } from '@components/templates/EmptyState';
|
||||||
|
import { useOpportunities } from '@features/crm/hooks';
|
||||||
|
import type { Opportunity, OpportunityStatus } from '@features/crm/types';
|
||||||
|
import { formatDate, formatNumber } from '@utils/formatters';
|
||||||
|
|
||||||
|
const statusLabels: Record<OpportunityStatus, string> = {
|
||||||
|
open: 'Abierta',
|
||||||
|
won: 'Ganada',
|
||||||
|
lost: 'Perdida',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColors: Record<OpportunityStatus, string> = {
|
||||||
|
open: 'bg-blue-100 text-blue-700',
|
||||||
|
won: 'bg-green-100 text-green-700',
|
||||||
|
lost: 'bg-red-100 text-red-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (value: number): string => {
|
||||||
|
return formatNumber(value, 'es-MX', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OpportunitiesPage() {
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState<OpportunityStatus | ''>('');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [oppToWin, setOppToWin] = useState<Opportunity | null>(null);
|
||||||
|
const [oppToLose, setOppToLose] = useState<Opportunity | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
opportunities,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
setPage,
|
||||||
|
refresh,
|
||||||
|
markWon,
|
||||||
|
markLost,
|
||||||
|
} = useOpportunities({
|
||||||
|
status: selectedStatus || undefined,
|
||||||
|
search: searchTerm || undefined,
|
||||||
|
limit: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getActionsMenu = (opp: Opportunity): DropdownItem[] => {
|
||||||
|
const items: DropdownItem[] = [
|
||||||
|
{
|
||||||
|
key: 'view',
|
||||||
|
label: 'Ver detalle',
|
||||||
|
icon: <Eye className="h-4 w-4" />,
|
||||||
|
onClick: () => console.log('View', opp.id),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (opp.status === 'open') {
|
||||||
|
items.push({
|
||||||
|
key: 'win',
|
||||||
|
label: 'Marcar como ganada',
|
||||||
|
icon: <Trophy className="h-4 w-4" />,
|
||||||
|
onClick: () => setOppToWin(opp),
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
key: 'lose',
|
||||||
|
label: 'Marcar como perdida',
|
||||||
|
icon: <XCircle className="h-4 w-4" />,
|
||||||
|
danger: true,
|
||||||
|
onClick: () => setOppToLose(opp),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: Column<Opportunity>[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'Oportunidad',
|
||||||
|
render: (opp) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-indigo-50">
|
||||||
|
<Target className="h-5 w-5 text-indigo-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">{opp.name}</div>
|
||||||
|
{opp.stageName && (
|
||||||
|
<div className="text-sm text-gray-500">{opp.stageName}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'partner',
|
||||||
|
header: 'Cliente',
|
||||||
|
render: (opp) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building className="h-4 w-4 text-gray-400" />
|
||||||
|
<span className="text-sm text-gray-900">{opp.partnerName || opp.partnerId}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'expectedRevenue',
|
||||||
|
header: 'Ingreso Esperado',
|
||||||
|
sortable: true,
|
||||||
|
render: (opp) => (
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="font-medium text-gray-900">
|
||||||
|
${formatCurrency(opp.expectedRevenue || 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'probability',
|
||||||
|
header: 'Probabilidad',
|
||||||
|
render: (opp) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-2 w-16 rounded-full bg-gray-200">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded-full ${opp.probability >= 70 ? 'bg-green-500' : opp.probability >= 40 ? 'bg-yellow-500' : 'bg-red-500'}`}
|
||||||
|
style={{ width: `${opp.probability}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-600">{opp.probability}%</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'expectedCloseDate',
|
||||||
|
header: 'Cierre Esperado',
|
||||||
|
sortable: true,
|
||||||
|
render: (opp) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-4 w-4 text-gray-400" />
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{opp.expectedCloseDate ? formatDate(opp.expectedCloseDate, 'short') : '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'user',
|
||||||
|
header: 'Responsable',
|
||||||
|
render: (opp) => (
|
||||||
|
<span className="text-sm text-gray-600">{opp.userName || '-'}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: 'Estado',
|
||||||
|
render: (opp) => (
|
||||||
|
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${statusColors[opp.status]}`}>
|
||||||
|
{statusLabels[opp.status]}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
render: (opp) => (
|
||||||
|
<Dropdown
|
||||||
|
trigger={
|
||||||
|
<button className="rounded p-1 hover:bg-gray-100">
|
||||||
|
<MoreVertical className="h-4 w-4 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
items={getActionsMenu(opp)}
|
||||||
|
align="right"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleWin = async () => {
|
||||||
|
if (oppToWin) {
|
||||||
|
await markWon(oppToWin.id);
|
||||||
|
setOppToWin(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLose = async () => {
|
||||||
|
if (oppToLose) {
|
||||||
|
await markLost(oppToLose.id, 'Perdida por el usuario');
|
||||||
|
setOppToLose(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate summary stats
|
||||||
|
const openCount = opportunities.filter(o => o.status === 'open').length;
|
||||||
|
const wonCount = opportunities.filter(o => o.status === 'won').length;
|
||||||
|
const totalPipeline = opportunities
|
||||||
|
.filter(o => o.status === 'open')
|
||||||
|
.reduce((sum, o) => sum + (o.expectedRevenue || 0), 0);
|
||||||
|
const weightedPipeline = opportunities
|
||||||
|
.filter(o => o.status === 'open')
|
||||||
|
.reduce((sum, o) => sum + ((o.expectedRevenue || 0) * (o.probability / 100)), 0);
|
||||||
|
const wonRevenue = opportunities
|
||||||
|
.filter(o => o.status === 'won')
|
||||||
|
.reduce((sum, o) => sum + (o.expectedRevenue || 0), 0);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<ErrorEmptyState onRetry={refresh} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
<Breadcrumbs items={[
|
||||||
|
{ label: 'CRM', href: '/crm' },
|
||||||
|
{ label: 'Oportunidades' },
|
||||||
|
]} />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Oportunidades</h1>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Gestiona el pipeline de ventas y cierra negocios
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={refresh} disabled={isLoading}>
|
||||||
|
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||||
|
Actualizar
|
||||||
|
</Button>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Nueva oportunidad
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Stats */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedStatus('open')}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
|
||||||
|
<Target className="h-5 w-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-500">Abiertas</div>
|
||||||
|
<div className="text-xl font-bold text-blue-600">{openCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedStatus('won')}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
|
||||||
|
<Trophy className="h-5 w-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-500">Ganadas</div>
|
||||||
|
<div className="text-xl font-bold text-green-600">{wonCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-indigo-100">
|
||||||
|
<DollarSign className="h-5 w-5 text-indigo-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-500">Pipeline</div>
|
||||||
|
<div className="text-xl font-bold text-indigo-600">${formatCurrency(totalPipeline)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-100">
|
||||||
|
<TrendingUp className="h-5 w-5 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-500">Ponderado</div>
|
||||||
|
<div className="text-xl font-bold text-amber-600">${formatCurrency(weightedPipeline)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
|
||||||
|
<DollarSign className="h-5 w-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-500">Ganado</div>
|
||||||
|
<div className="text-xl font-bold text-green-600">${formatCurrency(wonRevenue)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Lista de Oportunidades</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<div className="relative flex-1 min-w-[200px]">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar oportunidades..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-gray-300 py-2 pl-10 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={selectedStatus}
|
||||||
|
onChange={(e) => setSelectedStatus(e.target.value as OpportunityStatus | '')}
|
||||||
|
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Todos los estados</option>
|
||||||
|
{Object.entries(statusLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{(selectedStatus || searchTerm) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedStatus('');
|
||||||
|
setSearchTerm('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Limpiar filtros
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{opportunities.length === 0 && !isLoading ? (
|
||||||
|
<NoDataEmptyState
|
||||||
|
entityName="oportunidades"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={opportunities}
|
||||||
|
columns={columns}
|
||||||
|
isLoading={isLoading}
|
||||||
|
pagination={{
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
total,
|
||||||
|
limit: 20,
|
||||||
|
onPageChange: setPage,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Win Opportunity Modal */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={!!oppToWin}
|
||||||
|
onClose={() => setOppToWin(null)}
|
||||||
|
onConfirm={handleWin}
|
||||||
|
title="Marcar como ganada"
|
||||||
|
message={`¿Marcar la oportunidad "${oppToWin?.name}" como ganada?`}
|
||||||
|
variant="success"
|
||||||
|
confirmText="Marcar como ganada"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Lose Opportunity Modal */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={!!oppToLose}
|
||||||
|
onClose={() => setOppToLose(null)}
|
||||||
|
onConfirm={handleLose}
|
||||||
|
title="Marcar como perdida"
|
||||||
|
message={`¿Marcar la oportunidad "${oppToLose?.name}" como perdida? Esta accion no se puede deshacer.`}
|
||||||
|
variant="danger"
|
||||||
|
confirmText="Marcar como perdida"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OpportunitiesPage;
|
||||||
2
src/pages/crm/index.ts
Normal file
2
src/pages/crm/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { LeadsPage, default as LeadsPageDefault } from './LeadsPage';
|
||||||
|
export { OpportunitiesPage, default as OpportunitiesPageDefault } from './OpportunitiesPage';
|
||||||
457
src/pages/projects/ProjectsPage.tsx
Normal file
457
src/pages/projects/ProjectsPage.tsx
Normal file
@ -0,0 +1,457 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
FolderKanban,
|
||||||
|
Plus,
|
||||||
|
MoreVertical,
|
||||||
|
Eye,
|
||||||
|
Play,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Calendar,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
Clock,
|
||||||
|
Users,
|
||||||
|
BarChart3,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@components/atoms/Button';
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||||
|
import { DataTable, type Column } from '@components/organisms/DataTable';
|
||||||
|
import { Dropdown, type DropdownItem } from '@components/organisms/Dropdown';
|
||||||
|
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||||
|
import { ConfirmModal } from '@components/organisms/Modal';
|
||||||
|
import { NoDataEmptyState, ErrorEmptyState } from '@components/templates/EmptyState';
|
||||||
|
import { useProjects } from '@features/projects/hooks';
|
||||||
|
import type { Project, ProjectStatus } from '@features/projects/types';
|
||||||
|
import { formatDate, formatNumber } from '@utils/formatters';
|
||||||
|
|
||||||
|
const statusLabels: Record<ProjectStatus, string> = {
|
||||||
|
draft: 'Borrador',
|
||||||
|
active: 'Activo',
|
||||||
|
completed: 'Completado',
|
||||||
|
cancelled: 'Cancelado',
|
||||||
|
on_hold: 'En Espera',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColors: Record<ProjectStatus, string> = {
|
||||||
|
draft: 'bg-gray-100 text-gray-700',
|
||||||
|
active: 'bg-green-100 text-green-700',
|
||||||
|
completed: 'bg-blue-100 text-blue-700',
|
||||||
|
cancelled: 'bg-red-100 text-red-700',
|
||||||
|
on_hold: 'bg-amber-100 text-amber-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProjectsPage() {
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState<ProjectStatus | ''>('');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [projectToActivate, setProjectToActivate] = useState<Project | null>(null);
|
||||||
|
const [projectToComplete, setProjectToComplete] = useState<Project | null>(null);
|
||||||
|
const [projectToCancel, setProjectToCancel] = useState<Project | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
projects,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
setPage,
|
||||||
|
refresh,
|
||||||
|
activateProject,
|
||||||
|
completeProject,
|
||||||
|
cancelProject,
|
||||||
|
} = useProjects({
|
||||||
|
status: selectedStatus || undefined,
|
||||||
|
search: searchTerm || undefined,
|
||||||
|
limit: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getActionsMenu = (project: Project): DropdownItem[] => {
|
||||||
|
const items: DropdownItem[] = [
|
||||||
|
{
|
||||||
|
key: 'view',
|
||||||
|
label: 'Ver detalle',
|
||||||
|
icon: <Eye className="h-4 w-4" />,
|
||||||
|
onClick: () => console.log('View', project.id),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (project.status === 'draft') {
|
||||||
|
items.push({
|
||||||
|
key: 'activate',
|
||||||
|
label: 'Activar proyecto',
|
||||||
|
icon: <Play className="h-4 w-4" />,
|
||||||
|
onClick: () => setProjectToActivate(project),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (project.status === 'active') {
|
||||||
|
items.push({
|
||||||
|
key: 'complete',
|
||||||
|
label: 'Completar proyecto',
|
||||||
|
icon: <CheckCircle className="h-4 w-4" />,
|
||||||
|
onClick: () => setProjectToComplete(project),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (project.status !== 'completed' && project.status !== 'cancelled') {
|
||||||
|
items.push({
|
||||||
|
key: 'cancel',
|
||||||
|
label: 'Cancelar proyecto',
|
||||||
|
icon: <XCircle className="h-4 w-4" />,
|
||||||
|
danger: true,
|
||||||
|
onClick: () => setProjectToCancel(project),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: Column<Project>[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'Proyecto',
|
||||||
|
render: (project) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="flex h-10 w-10 items-center justify-center rounded-lg"
|
||||||
|
style={{ backgroundColor: project.color ? `${project.color}20` : '#f3f4f6' }}
|
||||||
|
>
|
||||||
|
<FolderKanban
|
||||||
|
className="h-5 w-5"
|
||||||
|
style={{ color: project.color || '#6b7280' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">{project.name}</div>
|
||||||
|
{project.code && (
|
||||||
|
<div className="text-sm text-gray-500">{project.code}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'manager',
|
||||||
|
header: 'Responsable',
|
||||||
|
render: (project) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="h-4 w-4 text-gray-400" />
|
||||||
|
<span className="text-sm text-gray-900">{project.managerName || '-'}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dates',
|
||||||
|
header: 'Fechas',
|
||||||
|
render: (project) => (
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
{project.dateStart && project.dateEnd ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-4 w-4 text-gray-400" />
|
||||||
|
{formatDate(project.dateStart, 'short')} - {formatDate(project.dateEnd, 'short')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">Sin fechas</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tasks',
|
||||||
|
header: 'Tareas',
|
||||||
|
render: (project) => {
|
||||||
|
const completed = project.completedTaskCount || 0;
|
||||||
|
const total = project.taskCount || 0;
|
||||||
|
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-2 w-16 rounded-full bg-gray-200">
|
||||||
|
<div
|
||||||
|
className="h-2 rounded-full bg-green-500"
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-600">{completed}/{total}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'hours',
|
||||||
|
header: 'Horas',
|
||||||
|
render: (project) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4 text-gray-400" />
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{project.totalHours || 0}h / {project.plannedHours || 0}h
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: 'Estado',
|
||||||
|
render: (project) => (
|
||||||
|
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${statusColors[project.status]}`}>
|
||||||
|
{statusLabels[project.status]}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
render: (project) => (
|
||||||
|
<Dropdown
|
||||||
|
trigger={
|
||||||
|
<button className="rounded p-1 hover:bg-gray-100">
|
||||||
|
<MoreVertical className="h-4 w-4 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
items={getActionsMenu(project)}
|
||||||
|
align="right"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleActivate = async () => {
|
||||||
|
if (projectToActivate) {
|
||||||
|
await activateProject(projectToActivate.id);
|
||||||
|
setProjectToActivate(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleComplete = async () => {
|
||||||
|
if (projectToComplete) {
|
||||||
|
await completeProject(projectToComplete.id);
|
||||||
|
setProjectToComplete(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
if (projectToCancel) {
|
||||||
|
await cancelProject(projectToCancel.id);
|
||||||
|
setProjectToCancel(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate summary stats
|
||||||
|
const draftCount = projects.filter(p => p.status === 'draft').length;
|
||||||
|
const activeCount = projects.filter(p => p.status === 'active').length;
|
||||||
|
const completedCount = projects.filter(p => p.status === 'completed').length;
|
||||||
|
const totalTasks = projects.reduce((sum, p) => sum + (p.taskCount || 0), 0);
|
||||||
|
const completedTasks = projects.reduce((sum, p) => sum + (p.completedTaskCount || 0), 0);
|
||||||
|
const totalHours = projects.reduce((sum, p) => sum + (p.totalHours || 0), 0);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<ErrorEmptyState onRetry={refresh} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
<Breadcrumbs items={[
|
||||||
|
{ label: 'Proyectos', href: '/projects' },
|
||||||
|
{ label: 'Lista' },
|
||||||
|
]} />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Proyectos</h1>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Gestiona proyectos, tareas y seguimiento de tiempo
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={refresh} disabled={isLoading}>
|
||||||
|
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||||
|
Actualizar
|
||||||
|
</Button>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Nuevo proyecto
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Stats */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedStatus('draft')}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100">
|
||||||
|
<FolderKanban className="h-5 w-5 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-500">Borradores</div>
|
||||||
|
<div className="text-xl font-bold text-gray-900">{draftCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedStatus('active')}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
|
||||||
|
<Play className="h-5 w-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-500">Activos</div>
|
||||||
|
<div className="text-xl font-bold text-green-600">{activeCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedStatus('completed')}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
|
||||||
|
<CheckCircle className="h-5 w-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-500">Completados</div>
|
||||||
|
<div className="text-xl font-bold text-blue-600">{completedCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-indigo-100">
|
||||||
|
<BarChart3 className="h-5 w-5 text-indigo-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-500">Tareas</div>
|
||||||
|
<div className="text-xl font-bold text-indigo-600">{completedTasks}/{totalTasks}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-100">
|
||||||
|
<Clock className="h-5 w-5 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-500">Horas Totales</div>
|
||||||
|
<div className="text-xl font-bold text-amber-600">{formatNumber(totalHours, 'es-MX', { maximumFractionDigits: 1 })}h</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Lista de Proyectos</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<div className="relative flex-1 min-w-[200px]">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar proyectos..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-gray-300 py-2 pl-10 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={selectedStatus}
|
||||||
|
onChange={(e) => setSelectedStatus(e.target.value as ProjectStatus | '')}
|
||||||
|
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Todos los estados</option>
|
||||||
|
{Object.entries(statusLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{(selectedStatus || searchTerm) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedStatus('');
|
||||||
|
setSearchTerm('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Limpiar filtros
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{projects.length === 0 && !isLoading ? (
|
||||||
|
<NoDataEmptyState
|
||||||
|
entityName="proyectos"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={projects}
|
||||||
|
columns={columns}
|
||||||
|
isLoading={isLoading}
|
||||||
|
pagination={{
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
total,
|
||||||
|
limit: 20,
|
||||||
|
onPageChange: setPage,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Activate Project Modal */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={!!projectToActivate}
|
||||||
|
onClose={() => setProjectToActivate(null)}
|
||||||
|
onConfirm={handleActivate}
|
||||||
|
title="Activar proyecto"
|
||||||
|
message={`¿Activar el proyecto "${projectToActivate?.name}"?`}
|
||||||
|
variant="info"
|
||||||
|
confirmText="Activar"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Complete Project Modal */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={!!projectToComplete}
|
||||||
|
onClose={() => setProjectToComplete(null)}
|
||||||
|
onConfirm={handleComplete}
|
||||||
|
title="Completar proyecto"
|
||||||
|
message={`¿Marcar el proyecto "${projectToComplete?.name}" como completado?`}
|
||||||
|
variant="success"
|
||||||
|
confirmText="Completar"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Cancel Project Modal */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={!!projectToCancel}
|
||||||
|
onClose={() => setProjectToCancel(null)}
|
||||||
|
onConfirm={handleCancel}
|
||||||
|
title="Cancelar proyecto"
|
||||||
|
message={`¿Cancelar el proyecto "${projectToCancel?.name}"? Esta accion no se puede deshacer.`}
|
||||||
|
variant="danger"
|
||||||
|
confirmText="Cancelar proyecto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProjectsPage;
|
||||||
479
src/pages/projects/TasksPage.tsx
Normal file
479
src/pages/projects/TasksPage.tsx
Normal file
@ -0,0 +1,479 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
ListTodo,
|
||||||
|
Plus,
|
||||||
|
MoreVertical,
|
||||||
|
Eye,
|
||||||
|
Play,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Calendar,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
Clock,
|
||||||
|
User,
|
||||||
|
Flag,
|
||||||
|
FolderKanban,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@components/atoms/Button';
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||||
|
import { DataTable, type Column } from '@components/organisms/DataTable';
|
||||||
|
import { Dropdown, type DropdownItem } from '@components/organisms/Dropdown';
|
||||||
|
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||||
|
import { ConfirmModal } from '@components/organisms/Modal';
|
||||||
|
import { NoDataEmptyState, ErrorEmptyState } from '@components/templates/EmptyState';
|
||||||
|
import { useTasks } from '@features/projects/hooks';
|
||||||
|
import type { Task, TaskStatus, TaskPriority } from '@features/projects/types';
|
||||||
|
import { formatDate, formatNumber } from '@utils/formatters';
|
||||||
|
|
||||||
|
const statusLabels: Record<TaskStatus, string> = {
|
||||||
|
todo: 'Por Hacer',
|
||||||
|
in_progress: 'En Progreso',
|
||||||
|
review: 'En Revision',
|
||||||
|
done: 'Completada',
|
||||||
|
cancelled: 'Cancelada',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColors: Record<TaskStatus, string> = {
|
||||||
|
todo: 'bg-gray-100 text-gray-700',
|
||||||
|
in_progress: 'bg-blue-100 text-blue-700',
|
||||||
|
review: 'bg-yellow-100 text-yellow-700',
|
||||||
|
done: 'bg-green-100 text-green-700',
|
||||||
|
cancelled: 'bg-red-100 text-red-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
const priorityLabels: Record<TaskPriority, string> = {
|
||||||
|
low: 'Baja',
|
||||||
|
normal: 'Normal',
|
||||||
|
high: 'Alta',
|
||||||
|
urgent: 'Urgente',
|
||||||
|
};
|
||||||
|
|
||||||
|
const priorityColors: Record<TaskPriority, string> = {
|
||||||
|
low: 'text-gray-500',
|
||||||
|
normal: 'text-blue-500',
|
||||||
|
high: 'text-amber-500',
|
||||||
|
urgent: 'text-red-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TasksPage() {
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState<TaskStatus | ''>('');
|
||||||
|
const [selectedPriority, setSelectedPriority] = useState<TaskPriority | ''>('');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [taskToStart, setTaskToStart] = useState<Task | null>(null);
|
||||||
|
const [taskToComplete, setTaskToComplete] = useState<Task | null>(null);
|
||||||
|
const [taskToCancel, setTaskToCancel] = useState<Task | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
tasks,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
setPage,
|
||||||
|
refresh,
|
||||||
|
startTask,
|
||||||
|
completeTask,
|
||||||
|
cancelTask,
|
||||||
|
} = useTasks({
|
||||||
|
status: selectedStatus || undefined,
|
||||||
|
priority: selectedPriority || undefined,
|
||||||
|
search: searchTerm || undefined,
|
||||||
|
limit: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getActionsMenu = (task: Task): DropdownItem[] => {
|
||||||
|
const items: DropdownItem[] = [
|
||||||
|
{
|
||||||
|
key: 'view',
|
||||||
|
label: 'Ver detalle',
|
||||||
|
icon: <Eye className="h-4 w-4" />,
|
||||||
|
onClick: () => console.log('View', task.id),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (task.status === 'todo') {
|
||||||
|
items.push({
|
||||||
|
key: 'start',
|
||||||
|
label: 'Iniciar tarea',
|
||||||
|
icon: <Play className="h-4 w-4" />,
|
||||||
|
onClick: () => setTaskToStart(task),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.status === 'in_progress' || task.status === 'review') {
|
||||||
|
items.push({
|
||||||
|
key: 'complete',
|
||||||
|
label: 'Completar tarea',
|
||||||
|
icon: <CheckCircle className="h-4 w-4" />,
|
||||||
|
onClick: () => setTaskToComplete(task),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.status !== 'done' && task.status !== 'cancelled') {
|
||||||
|
items.push({
|
||||||
|
key: 'cancel',
|
||||||
|
label: 'Cancelar tarea',
|
||||||
|
icon: <XCircle className="h-4 w-4" />,
|
||||||
|
danger: true,
|
||||||
|
onClick: () => setTaskToCancel(task),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: Column<Task>[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'Tarea',
|
||||||
|
render: (task) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-indigo-50">
|
||||||
|
<ListTodo className="h-5 w-5 text-indigo-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">{task.name}</div>
|
||||||
|
{task.projectName && (
|
||||||
|
<div className="flex items-center gap-1 text-sm text-gray-500">
|
||||||
|
<FolderKanban className="h-3 w-3" />
|
||||||
|
{task.projectName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'assignedTo',
|
||||||
|
header: 'Asignado',
|
||||||
|
render: (task) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<User className="h-4 w-4 text-gray-400" />
|
||||||
|
<span className="text-sm text-gray-900">{task.assignedToName || 'Sin asignar'}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'priority',
|
||||||
|
header: 'Prioridad',
|
||||||
|
render: (task) => (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Flag className={`h-4 w-4 ${priorityColors[task.priority]}`} />
|
||||||
|
<span className={`text-sm ${priorityColors[task.priority]}`}>
|
||||||
|
{priorityLabels[task.priority]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'deadline',
|
||||||
|
header: 'Fecha Limite',
|
||||||
|
sortable: true,
|
||||||
|
render: (task) => {
|
||||||
|
if (!task.dateDeadline) {
|
||||||
|
return <span className="text-sm text-gray-400">Sin fecha</span>;
|
||||||
|
}
|
||||||
|
const isOverdue = new Date(task.dateDeadline) < new Date() && task.status !== 'done';
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-1 text-sm ${isOverdue ? 'text-red-600' : 'text-gray-600'}`}>
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
{formatDate(task.dateDeadline, 'short')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'hours',
|
||||||
|
header: 'Horas',
|
||||||
|
render: (task) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4 text-gray-400" />
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{task.spentHours || 0}h / {task.estimatedHours || 0}h
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: 'Estado',
|
||||||
|
render: (task) => (
|
||||||
|
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${statusColors[task.status]}`}>
|
||||||
|
{statusLabels[task.status]}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
render: (task) => (
|
||||||
|
<Dropdown
|
||||||
|
trigger={
|
||||||
|
<button className="rounded p-1 hover:bg-gray-100">
|
||||||
|
<MoreVertical className="h-4 w-4 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
items={getActionsMenu(task)}
|
||||||
|
align="right"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleStart = async () => {
|
||||||
|
if (taskToStart) {
|
||||||
|
await startTask(taskToStart.id);
|
||||||
|
setTaskToStart(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleComplete = async () => {
|
||||||
|
if (taskToComplete) {
|
||||||
|
await completeTask(taskToComplete.id);
|
||||||
|
setTaskToComplete(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
if (taskToCancel) {
|
||||||
|
await cancelTask(taskToCancel.id);
|
||||||
|
setTaskToCancel(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate summary stats
|
||||||
|
const todoCount = tasks.filter(t => t.status === 'todo').length;
|
||||||
|
const inProgressCount = tasks.filter(t => t.status === 'in_progress').length;
|
||||||
|
const doneCount = tasks.filter(t => t.status === 'done').length;
|
||||||
|
const urgentCount = tasks.filter(t => t.priority === 'urgent' && t.status !== 'done').length;
|
||||||
|
const totalEstimated = tasks.reduce((sum, t) => sum + (t.estimatedHours || 0), 0);
|
||||||
|
const totalSpent = tasks.reduce((sum, t) => sum + (t.spentHours || 0), 0);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<ErrorEmptyState onRetry={refresh} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
<Breadcrumbs items={[
|
||||||
|
{ label: 'Proyectos', href: '/projects' },
|
||||||
|
{ label: 'Tareas' },
|
||||||
|
]} />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Tareas</h1>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Gestiona y da seguimiento a todas las tareas de proyectos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={refresh} disabled={isLoading}>
|
||||||
|
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||||
|
Actualizar
|
||||||
|
</Button>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Nueva tarea
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Stats */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedStatus('todo')}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100">
|
||||||
|
<ListTodo className="h-5 w-5 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-500">Por Hacer</div>
|
||||||
|
<div className="text-xl font-bold text-gray-900">{todoCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedStatus('in_progress')}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
|
||||||
|
<Play className="h-5 w-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-500">En Progreso</div>
|
||||||
|
<div className="text-xl font-bold text-blue-600">{inProgressCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedStatus('done')}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-500">Completadas</div>
|
||||||
|
<div className="text-xl font-bold text-green-600">{doneCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedPriority('urgent')}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-100">
|
||||||
|
<Flag className="h-5 w-5 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-500">Urgentes</div>
|
||||||
|
<div className="text-xl font-bold text-red-600">{urgentCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-100">
|
||||||
|
<Clock className="h-5 w-5 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-500">Horas</div>
|
||||||
|
<div className="text-xl font-bold text-amber-600">
|
||||||
|
{formatNumber(totalSpent, 'es-MX', { maximumFractionDigits: 1 })}/
|
||||||
|
{formatNumber(totalEstimated, 'es-MX', { maximumFractionDigits: 1 })}h
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Lista de Tareas</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<div className="relative flex-1 min-w-[200px]">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar tareas..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-gray-300 py-2 pl-10 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={selectedStatus}
|
||||||
|
onChange={(e) => setSelectedStatus(e.target.value as TaskStatus | '')}
|
||||||
|
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Todos los estados</option>
|
||||||
|
{Object.entries(statusLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={selectedPriority}
|
||||||
|
onChange={(e) => setSelectedPriority(e.target.value as TaskPriority | '')}
|
||||||
|
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Todas las prioridades</option>
|
||||||
|
{Object.entries(priorityLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{(selectedStatus || selectedPriority || searchTerm) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedStatus('');
|
||||||
|
setSelectedPriority('');
|
||||||
|
setSearchTerm('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Limpiar filtros
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{tasks.length === 0 && !isLoading ? (
|
||||||
|
<NoDataEmptyState
|
||||||
|
entityName="tareas"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
data={tasks}
|
||||||
|
columns={columns}
|
||||||
|
isLoading={isLoading}
|
||||||
|
pagination={{
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
total,
|
||||||
|
limit: 20,
|
||||||
|
onPageChange: setPage,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Start Task Modal */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={!!taskToStart}
|
||||||
|
onClose={() => setTaskToStart(null)}
|
||||||
|
onConfirm={handleStart}
|
||||||
|
title="Iniciar tarea"
|
||||||
|
message={`¿Iniciar la tarea "${taskToStart?.name}"?`}
|
||||||
|
variant="info"
|
||||||
|
confirmText="Iniciar"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Complete Task Modal */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={!!taskToComplete}
|
||||||
|
onClose={() => setTaskToComplete(null)}
|
||||||
|
onConfirm={handleComplete}
|
||||||
|
title="Completar tarea"
|
||||||
|
message={`¿Marcar la tarea "${taskToComplete?.name}" como completada?`}
|
||||||
|
variant="success"
|
||||||
|
confirmText="Completar"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Cancel Task Modal */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={!!taskToCancel}
|
||||||
|
onClose={() => setTaskToCancel(null)}
|
||||||
|
onConfirm={handleCancel}
|
||||||
|
title="Cancelar tarea"
|
||||||
|
message={`¿Cancelar la tarea "${taskToCancel?.name}"? Esta accion no se puede deshacer.`}
|
||||||
|
variant="danger"
|
||||||
|
confirmText="Cancelar tarea"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TasksPage;
|
||||||
2
src/pages/projects/index.ts
Normal file
2
src/pages/projects/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { ProjectsPage, default as ProjectsPageDefault } from './ProjectsPage';
|
||||||
|
export { TasksPage, default as TasksPageDefault } from './TasksPage';
|
||||||
Loading…
Reference in New Issue
Block a user