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:
rckrdmrd 2026-01-18 10:36:23 -06:00
parent 54c14f87c8
commit 07987788f8
20 changed files with 3778 additions and 0 deletions

View 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;
},
};

View File

@ -0,0 +1 @@
export * from './crm.api';

View File

@ -0,0 +1 @@
export * from './useCrm';

View 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,
};
}

View File

@ -0,0 +1,3 @@
export * from './api/crm.api';
export * from './types';
export * from './hooks';

View 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;
}

View File

@ -0,0 +1 @@
export * from './crm.types';

View File

@ -0,0 +1 @@
export * from './projects.api';

View 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 });
},
};

View File

@ -0,0 +1 @@
export * from './useProjects';

View 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,
};
}

View File

@ -0,0 +1,3 @@
export * from './api/projects.api';
export * from './types';
export * from './hooks';

View File

@ -0,0 +1 @@
export * from './projects.types';

View 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
View 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;

View 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
View File

@ -0,0 +1,2 @@
export { LeadsPage, default as LeadsPageDefault } from './LeadsPage';
export { OpportunitiesPage, default as OpportunitiesPageDefault } from './OpportunitiesPage';

View 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;

View 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;

View File

@ -0,0 +1,2 @@
export { ProjectsPage, default as ProjectsPageDefault } from './ProjectsPage';
export { TasksPage, default as TasksPageDefault } from './TasksPage';