diff --git a/src/features/crm/api/crm.api.ts b/src/features/crm/api/crm.api.ts new file mode 100644 index 0000000..e297bd7 --- /dev/null +++ b/src/features/crm/api/crm.api.ts @@ -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 => { + 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(`${CRM_BASE}/leads?${params}`); + return response.data; + }, + + getById: async (id: string): Promise => { + const response = await api.get(`${CRM_BASE}/leads/${id}`); + return response.data; + }, + + create: async (data: LeadCreateInput): Promise => { + const response = await api.post(`${CRM_BASE}/leads`, data); + return response.data; + }, + + update: async (id: string, data: LeadUpdateInput): Promise => { + const response = await api.patch(`${CRM_BASE}/leads/${id}`, data); + return response.data; + }, + + delete: async (id: string): Promise => { + 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 => { + const response = await api.post(`${CRM_BASE}/leads/${id}/lost`, { reason }); + return response.data; + }, +}; + +// ============================================================================ +// Opportunities API +// ============================================================================ + +export const opportunitiesApi = { + getAll: async (filters: OpportunityFilters = {}): Promise => { + 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(`${CRM_BASE}/opportunities?${params}`); + return response.data; + }, + + getById: async (id: string): Promise => { + const response = await api.get(`${CRM_BASE}/opportunities/${id}`); + return response.data; + }, + + create: async (data: OpportunityCreateInput): Promise => { + const response = await api.post(`${CRM_BASE}/opportunities`, data); + return response.data; + }, + + update: async (id: string, data: OpportunityUpdateInput): Promise => { + const response = await api.patch(`${CRM_BASE}/opportunities/${id}`, data); + return response.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`${CRM_BASE}/opportunities/${id}`); + }, + + markWon: async (id: string): Promise => { + const response = await api.post(`${CRM_BASE}/opportunities/${id}/won`); + return response.data; + }, + + markLost: async (id: string, reason?: string): Promise => { + const response = await api.post(`${CRM_BASE}/opportunities/${id}/lost`, { reason }); + return response.data; + }, + + getPipeline: async (companyId?: string): Promise => { + const params = companyId ? `?company_id=${companyId}` : ''; + const response = await api.get(`${CRM_BASE}/opportunities/pipeline${params}`); + return response.data; + }, +}; + +// ============================================================================ +// Stages API +// ============================================================================ + +export const stagesApi = { + getAll: async (type?: StageType, companyId?: string): Promise => { + const params = new URLSearchParams(); + if (type) params.append('type', type); + if (companyId) params.append('company_id', companyId); + + const response = await api.get(`${CRM_BASE}/stages?${params}`); + return response.data; + }, + + getById: async (id: string): Promise => { + const response = await api.get(`${CRM_BASE}/stages/${id}`); + return response.data; + }, + + create: async (data: StageCreateInput): Promise => { + const response = await api.post(`${CRM_BASE}/stages`, data); + return response.data; + }, + + update: async (id: string, data: StageUpdateInput): Promise => { + const response = await api.patch(`${CRM_BASE}/stages/${id}`, data); + return response.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`${CRM_BASE}/stages/${id}`); + }, + + reorder: async (stageIds: string[]): Promise => { + await api.post(`${CRM_BASE}/stages/reorder`, { stageIds }); + }, +}; + +// ============================================================================ +// Activities API +// ============================================================================ + +export const activitiesApi = { + getAll: async (filters: ActivityFilters = {}): Promise => { + 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(`${CRM_BASE}/activities?${params}`); + return response.data; + }, + + getById: async (id: string): Promise => { + const response = await api.get(`${CRM_BASE}/activities/${id}`); + return response.data; + }, + + create: async (data: ActivityCreateInput): Promise => { + const response = await api.post(`${CRM_BASE}/activities`, data); + return response.data; + }, + + update: async (id: string, data: ActivityUpdateInput): Promise => { + const response = await api.patch(`${CRM_BASE}/activities/${id}`, data); + return response.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`${CRM_BASE}/activities/${id}`); + }, + + markDone: async (id: string): Promise => { + const response = await api.post(`${CRM_BASE}/activities/${id}/done`); + return response.data; + }, + + cancel: async (id: string): Promise => { + const response = await api.post(`${CRM_BASE}/activities/${id}/cancel`); + return response.data; + }, +}; diff --git a/src/features/crm/api/index.ts b/src/features/crm/api/index.ts new file mode 100644 index 0000000..782c076 --- /dev/null +++ b/src/features/crm/api/index.ts @@ -0,0 +1 @@ +export * from './crm.api'; diff --git a/src/features/crm/hooks/index.ts b/src/features/crm/hooks/index.ts new file mode 100644 index 0000000..57d8512 --- /dev/null +++ b/src/features/crm/hooks/index.ts @@ -0,0 +1 @@ +export * from './useCrm'; diff --git a/src/features/crm/hooks/useCrm.ts b/src/features/crm/hooks/useCrm.ts new file mode 100644 index 0000000..7eac507 --- /dev/null +++ b/src/features/crm/hooks/useCrm.ts @@ -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([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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 => { + const lead = await leadsApi.create(data); + await fetchLeads(page); + return lead; + }, [fetchLeads, page]); + + const updateLead = useCallback(async (id: string, data: LeadUpdateInput): Promise => { + const lead = await leadsApi.update(id, data); + await fetchLeads(page); + return lead; + }, [fetchLeads, page]); + + const deleteLead = useCallback(async (id: string): Promise => { + 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 => { + 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([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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 => { + const opp = await opportunitiesApi.create(data); + await fetchOpportunities(page); + return opp; + }, [fetchOpportunities, page]); + + const updateOpportunity = useCallback(async (id: string, data: OpportunityUpdateInput): Promise => { + const opp = await opportunitiesApi.update(id, data); + await fetchOpportunities(page); + return opp; + }, [fetchOpportunities, page]); + + const deleteOpportunity = useCallback(async (id: string): Promise => { + await opportunitiesApi.delete(id); + await fetchOpportunities(page); + }, [fetchOpportunities, page]); + + const markWon = useCallback(async (id: string): Promise => { + const opp = await opportunitiesApi.markWon(id); + await fetchOpportunities(page); + return opp; + }, [fetchOpportunities, page]); + + const markLost = useCallback(async (id: string, reason?: string): Promise => { + 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(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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 => { + const stage = await stagesApi.create(data); + await fetchStages(); + return stage; + }, [fetchStages]); + + const updateStage = useCallback(async (id: string, data: StageUpdateInput): Promise => { + const stage = await stagesApi.update(id, data); + await fetchStages(); + return stage; + }, [fetchStages]); + + const deleteStage = useCallback(async (id: string): Promise => { + await stagesApi.delete(id); + await fetchStages(); + }, [fetchStages]); + + const reorderStages = useCallback(async (stageIds: string[]): Promise => { + 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([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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 => { + const activity = await activitiesApi.create(data); + await fetchActivities(page); + return activity; + }, [fetchActivities, page]); + + const updateActivity = useCallback(async (id: string, data: ActivityUpdateInput): Promise => { + const activity = await activitiesApi.update(id, data); + await fetchActivities(page); + return activity; + }, [fetchActivities, page]); + + const deleteActivity = useCallback(async (id: string): Promise => { + await activitiesApi.delete(id); + await fetchActivities(page); + }, [fetchActivities, page]); + + const markActivityDone = useCallback(async (id: string): Promise => { + const activity = await activitiesApi.markDone(id); + await fetchActivities(page); + return activity; + }, [fetchActivities, page]); + + const cancelActivity = useCallback(async (id: string): Promise => { + 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, + }; +} diff --git a/src/features/crm/index.ts b/src/features/crm/index.ts new file mode 100644 index 0000000..6028bdf --- /dev/null +++ b/src/features/crm/index.ts @@ -0,0 +1,3 @@ +export * from './api/crm.api'; +export * from './types'; +export * from './hooks'; diff --git a/src/features/crm/types/crm.types.ts b/src/features/crm/types/crm.types.ts new file mode 100644 index 0000000..7ba418b --- /dev/null +++ b/src/features/crm/types/crm.types.ts @@ -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 { + 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 { + 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 {} + +// ============================================================================ +// 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 { + 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; +} diff --git a/src/features/crm/types/index.ts b/src/features/crm/types/index.ts new file mode 100644 index 0000000..f7a6f09 --- /dev/null +++ b/src/features/crm/types/index.ts @@ -0,0 +1 @@ +export * from './crm.types'; diff --git a/src/features/projects/api/index.ts b/src/features/projects/api/index.ts new file mode 100644 index 0000000..e99b053 --- /dev/null +++ b/src/features/projects/api/index.ts @@ -0,0 +1 @@ +export * from './projects.api'; diff --git a/src/features/projects/api/projects.api.ts b/src/features/projects/api/projects.api.ts new file mode 100644 index 0000000..f74f7b6 --- /dev/null +++ b/src/features/projects/api/projects.api.ts @@ -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 => { + 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(`${PROJECTS_BASE}?${params}`); + return response.data; + }, + + getById: async (id: string): Promise => { + const response = await api.get(`${PROJECTS_BASE}/${id}`); + return response.data; + }, + + create: async (data: ProjectCreateInput): Promise => { + const response = await api.post(PROJECTS_BASE, data); + return response.data; + }, + + update: async (id: string, data: ProjectUpdateInput): Promise => { + const response = await api.patch(`${PROJECTS_BASE}/${id}`, data); + return response.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`${PROJECTS_BASE}/${id}`); + }, + + getStats: async (id: string): Promise => { + const response = await api.get(`${PROJECTS_BASE}/${id}/stats`); + return response.data; + }, + + activate: async (id: string): Promise => { + const response = await api.post(`${PROJECTS_BASE}/${id}/activate`); + return response.data; + }, + + complete: async (id: string): Promise => { + const response = await api.post(`${PROJECTS_BASE}/${id}/complete`); + return response.data; + }, + + cancel: async (id: string): Promise => { + const response = await api.post(`${PROJECTS_BASE}/${id}/cancel`); + return response.data; + }, + + hold: async (id: string): Promise => { + const response = await api.post(`${PROJECTS_BASE}/${id}/hold`); + return response.data; + }, +}; + +// ============================================================================ +// Tasks API +// ============================================================================ + +export const tasksApi = { + getAll: async (filters: TaskFilters = {}): Promise => { + 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(`${PROJECTS_BASE}/tasks?${params}`); + return response.data; + }, + + getById: async (id: string): Promise => { + const response = await api.get(`${PROJECTS_BASE}/tasks/${id}`); + return response.data; + }, + + create: async (data: TaskCreateInput): Promise => { + const response = await api.post(`${PROJECTS_BASE}/tasks`, data); + return response.data; + }, + + update: async (id: string, data: TaskUpdateInput): Promise => { + const response = await api.patch(`${PROJECTS_BASE}/tasks/${id}`, data); + return response.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`${PROJECTS_BASE}/tasks/${id}`); + }, + + getBoard: async (projectId: string): Promise => { + const response = await api.get(`${PROJECTS_BASE}/${projectId}/board`); + return response.data; + }, + + moveToStage: async (taskId: string, stageId: string, sequence?: number): Promise => { + const response = await api.post(`${PROJECTS_BASE}/tasks/${taskId}/move`, { + stageId, + sequence, + }); + return response.data; + }, + + start: async (id: string): Promise => { + const response = await api.post(`${PROJECTS_BASE}/tasks/${id}/start`); + return response.data; + }, + + complete: async (id: string): Promise => { + const response = await api.post(`${PROJECTS_BASE}/tasks/${id}/complete`); + return response.data; + }, + + cancel: async (id: string): Promise => { + const response = await api.post(`${PROJECTS_BASE}/tasks/${id}/cancel`); + return response.data; + }, +}; + +// ============================================================================ +// Task Stages API +// ============================================================================ + +export const taskStagesApi = { + getAll: async (projectId?: string): Promise => { + const params = projectId ? `?project_id=${projectId}` : ''; + const response = await api.get(`${PROJECTS_BASE}/stages${params}`); + return response.data; + }, + + getById: async (id: string): Promise => { + const response = await api.get(`${PROJECTS_BASE}/stages/${id}`); + return response.data; + }, + + create: async (data: TaskStageCreateInput): Promise => { + const response = await api.post(`${PROJECTS_BASE}/stages`, data); + return response.data; + }, + + update: async (id: string, data: TaskStageUpdateInput): Promise => { + const response = await api.patch(`${PROJECTS_BASE}/stages/${id}`, data); + return response.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`${PROJECTS_BASE}/stages/${id}`); + }, + + reorder: async (stageIds: string[]): Promise => { + await api.post(`${PROJECTS_BASE}/stages/reorder`, { stageIds }); + }, +}; + +// ============================================================================ +// Timesheets API +// ============================================================================ + +export const timesheetsApi = { + getAll: async (filters: TimesheetFilters = {}): Promise => { + 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(`${PROJECTS_BASE}/timesheets?${params}`); + return response.data; + }, + + getById: async (id: string): Promise => { + const response = await api.get(`${PROJECTS_BASE}/timesheets/${id}`); + return response.data; + }, + + create: async (data: TimesheetCreateInput): Promise => { + const response = await api.post(`${PROJECTS_BASE}/timesheets`, data); + return response.data; + }, + + update: async (id: string, data: TimesheetUpdateInput): Promise => { + const response = await api.patch(`${PROJECTS_BASE}/timesheets/${id}`, data); + return response.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`${PROJECTS_BASE}/timesheets/${id}`); + }, + + getByProject: async (projectId: string, filters: Omit = {}): Promise => { + return timesheetsApi.getAll({ ...filters, projectId }); + }, + + getByEmployee: async (employeeId: string, filters: Omit = {}): Promise => { + return timesheetsApi.getAll({ ...filters, employeeId }); + }, +}; diff --git a/src/features/projects/hooks/index.ts b/src/features/projects/hooks/index.ts new file mode 100644 index 0000000..73de7ee --- /dev/null +++ b/src/features/projects/hooks/index.ts @@ -0,0 +1 @@ +export * from './useProjects'; diff --git a/src/features/projects/hooks/useProjects.ts b/src/features/projects/hooks/useProjects.ts new file mode 100644 index 0000000..78292c4 --- /dev/null +++ b/src/features/projects/hooks/useProjects.ts @@ -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([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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 => { + const project = await projectsApi.create(data); + await fetchProjects(page); + return project; + }, [fetchProjects, page]); + + const updateProject = useCallback(async (id: string, data: ProjectUpdateInput): Promise => { + const project = await projectsApi.update(id, data); + await fetchProjects(page); + return project; + }, [fetchProjects, page]); + + const deleteProject = useCallback(async (id: string): Promise => { + await projectsApi.delete(id); + await fetchProjects(page); + }, [fetchProjects, page]); + + const activateProject = useCallback(async (id: string): Promise => { + const project = await projectsApi.activate(id); + await fetchProjects(page); + return project; + }, [fetchProjects, page]); + + const completeProject = useCallback(async (id: string): Promise => { + const project = await projectsApi.complete(id); + await fetchProjects(page); + return project; + }, [fetchProjects, page]); + + const cancelProject = useCallback(async (id: string): Promise => { + const project = await projectsApi.cancel(id); + await fetchProjects(page); + return project; + }, [fetchProjects, page]); + + const holdProject = useCallback(async (id: string): Promise => { + 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(null); + const [stats, setStats] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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 => { + const task = await tasksApi.create(data); + await fetchTasks(page); + return task; + }, [fetchTasks, page]); + + const updateTask = useCallback(async (id: string, data: TaskUpdateInput): Promise => { + const task = await tasksApi.update(id, data); + await fetchTasks(page); + return task; + }, [fetchTasks, page]); + + const deleteTask = useCallback(async (id: string): Promise => { + await tasksApi.delete(id); + await fetchTasks(page); + }, [fetchTasks, page]); + + const startTask = useCallback(async (id: string): Promise => { + const task = await tasksApi.start(id); + await fetchTasks(page); + return task; + }, [fetchTasks, page]); + + const completeTask = useCallback(async (id: string): Promise => { + const task = await tasksApi.complete(id); + await fetchTasks(page); + return task; + }, [fetchTasks, page]); + + const cancelTask = useCallback(async (id: string): Promise => { + const task = await tasksApi.cancel(id); + await fetchTasks(page); + return task; + }, [fetchTasks, page]); + + const moveTask = useCallback(async (taskId: string, stageId: string, sequence?: number): Promise => { + 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(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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 => { + 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([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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 => { + const stage = await taskStagesApi.create(data); + await fetchStages(); + return stage; + }, [fetchStages]); + + const updateStage = useCallback(async (id: string, data: TaskStageUpdateInput): Promise => { + const stage = await taskStagesApi.update(id, data); + await fetchStages(); + return stage; + }, [fetchStages]); + + const deleteStage = useCallback(async (id: string): Promise => { + await taskStagesApi.delete(id); + await fetchStages(); + }, [fetchStages]); + + const reorderStages = useCallback(async (stageIds: string[]): Promise => { + 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([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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 => { + const timesheet = await timesheetsApi.create(data); + await fetchTimesheets(page); + return timesheet; + }, [fetchTimesheets, page]); + + const updateTimesheet = useCallback(async (id: string, data: TimesheetUpdateInput): Promise => { + const timesheet = await timesheetsApi.update(id, data); + await fetchTimesheets(page); + return timesheet; + }, [fetchTimesheets, page]); + + const deleteTimesheet = useCallback(async (id: string): Promise => { + 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, + }; +} diff --git a/src/features/projects/index.ts b/src/features/projects/index.ts new file mode 100644 index 0000000..86405aa --- /dev/null +++ b/src/features/projects/index.ts @@ -0,0 +1,3 @@ +export * from './api/projects.api'; +export * from './types'; +export * from './hooks'; diff --git a/src/features/projects/types/index.ts b/src/features/projects/types/index.ts new file mode 100644 index 0000000..235dad5 --- /dev/null +++ b/src/features/projects/types/index.ts @@ -0,0 +1 @@ +export * from './projects.types'; diff --git a/src/features/projects/types/projects.types.ts b/src/features/projects/types/projects.types.ts new file mode 100644 index 0000000..c16913c --- /dev/null +++ b/src/features/projects/types/projects.types.ts @@ -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 {} + +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> {} + +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> {} + +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 {} + +// ============================================================================ +// 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; +} diff --git a/src/pages/crm/LeadsPage.tsx b/src/pages/crm/LeadsPage.tsx new file mode 100644 index 0000000..87810f3 --- /dev/null +++ b/src/pages/crm/LeadsPage.tsx @@ -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 = { + new: 'Nuevo', + contacted: 'Contactado', + qualified: 'Calificado', + converted: 'Convertido', + lost: 'Perdido', +}; + +const statusColors: Record = { + 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 = { + 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(''); + const [selectedSource, setSelectedSource] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [leadToConvert, setLeadToConvert] = useState(null); + const [leadToLose, setLeadToLose] = useState(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: , + onClick: () => console.log('View', lead.id), + }, + ]; + + if (lead.status !== 'converted' && lead.status !== 'lost') { + items.push({ + key: 'convert', + label: 'Convertir a oportunidad', + icon: , + onClick: () => setLeadToConvert(lead), + }); + items.push({ + key: 'lose', + label: 'Marcar como perdido', + icon: , + danger: true, + onClick: () => setLeadToLose(lead), + }); + } + + return items; + }; + + const columns: Column[] = [ + { + key: 'name', + header: 'Lead', + render: (lead) => ( +
+
+ +
+
+
{lead.name}
+ {lead.contactName && ( +
{lead.contactName}
+ )} +
+
+ ), + }, + { + key: 'contact', + header: 'Contacto', + render: (lead) => ( +
+ {lead.email && ( +
+ + {lead.email} +
+ )} + {lead.phone && ( +
+ + {lead.phone} +
+ )} +
+ ), + }, + { + key: 'source', + header: 'Origen', + render: (lead) => ( + + {lead.source ? sourceLabels[lead.source] : '-'} + + ), + }, + { + key: 'probability', + header: 'Probabilidad', + render: (lead) => ( +
+
+
+
+ {lead.probability}% +
+ ), + }, + { + key: 'expectedRevenue', + header: 'Ingreso Esperado', + render: (lead) => ( +
+ + {lead.expectedRevenue ? `$${formatCurrency(lead.expectedRevenue)}` : '-'} + +
+ ), + }, + { + key: 'priority', + header: 'Prioridad', + render: (lead) => ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ), + }, + { + key: 'status', + header: 'Estado', + render: (lead) => ( + + {statusLabels[lead.status]} + + ), + }, + { + key: 'actions', + header: '', + render: (lead) => ( + + + + } + 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 ( +
+ +
+ ); + } + + return ( +
+ + +
+
+

Leads

+

+ Gestiona prospectos y convierte leads en oportunidades +

+
+
+ + +
+
+ + {/* Summary Stats */} +
+ setSelectedStatus('new')}> + +
+
+ +
+
+
Nuevos
+
{newCount}
+
+
+
+
+ + setSelectedStatus('qualified')}> + +
+
+ +
+
+
Calificados
+
{qualifiedCount}
+
+
+
+
+ + setSelectedStatus('converted')}> + +
+
+ +
+
+
Convertidos
+
{convertedCount}
+
+
+
+
+ + + +
+
+ +
+
+
Potencial Total
+
${formatCurrency(totalExpectedRevenue)}
+
+
+
+
+
+ + + + Lista de Leads + + +
+ {/* Filters */} +
+
+ + 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" + /> +
+ + + + + + {(selectedStatus || selectedSource || searchTerm) && ( + + )} +
+ + {/* Table */} + {leads.length === 0 && !isLoading ? ( + + ) : ( + + )} +
+
+
+ + {/* Convert Lead Modal */} + setLeadToConvert(null)} + onConfirm={handleConvert} + title="Convertir lead" + message={`¿Convertir "${leadToConvert?.name}" en una oportunidad de venta?`} + variant="success" + confirmText="Convertir" + /> + + {/* Lose Lead Modal */} + 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" + /> +
+ ); +} + +export default LeadsPage; diff --git a/src/pages/crm/OpportunitiesPage.tsx b/src/pages/crm/OpportunitiesPage.tsx new file mode 100644 index 0000000..7f33e92 --- /dev/null +++ b/src/pages/crm/OpportunitiesPage.tsx @@ -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 = { + open: 'Abierta', + won: 'Ganada', + lost: 'Perdida', +}; + +const statusColors: Record = { + 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(''); + const [searchTerm, setSearchTerm] = useState(''); + const [oppToWin, setOppToWin] = useState(null); + const [oppToLose, setOppToLose] = useState(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: , + onClick: () => console.log('View', opp.id), + }, + ]; + + if (opp.status === 'open') { + items.push({ + key: 'win', + label: 'Marcar como ganada', + icon: , + onClick: () => setOppToWin(opp), + }); + items.push({ + key: 'lose', + label: 'Marcar como perdida', + icon: , + danger: true, + onClick: () => setOppToLose(opp), + }); + } + + return items; + }; + + const columns: Column[] = [ + { + key: 'name', + header: 'Oportunidad', + render: (opp) => ( +
+
+ +
+
+
{opp.name}
+ {opp.stageName && ( +
{opp.stageName}
+ )} +
+
+ ), + }, + { + key: 'partner', + header: 'Cliente', + render: (opp) => ( +
+ + {opp.partnerName || opp.partnerId} +
+ ), + }, + { + key: 'expectedRevenue', + header: 'Ingreso Esperado', + sortable: true, + render: (opp) => ( +
+
+ ${formatCurrency(opp.expectedRevenue || 0)} +
+
+ ), + }, + { + key: 'probability', + header: 'Probabilidad', + render: (opp) => ( +
+
+
= 70 ? 'bg-green-500' : opp.probability >= 40 ? 'bg-yellow-500' : 'bg-red-500'}`} + style={{ width: `${opp.probability}%` }} + /> +
+ {opp.probability}% +
+ ), + }, + { + key: 'expectedCloseDate', + header: 'Cierre Esperado', + sortable: true, + render: (opp) => ( +
+ + + {opp.expectedCloseDate ? formatDate(opp.expectedCloseDate, 'short') : '-'} + +
+ ), + }, + { + key: 'user', + header: 'Responsable', + render: (opp) => ( + {opp.userName || '-'} + ), + }, + { + key: 'status', + header: 'Estado', + render: (opp) => ( + + {statusLabels[opp.status]} + + ), + }, + { + key: 'actions', + header: '', + render: (opp) => ( + + + + } + 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 ( +
+ +
+ ); + } + + return ( +
+ + +
+
+

Oportunidades

+

+ Gestiona el pipeline de ventas y cierra negocios +

+
+
+ + +
+
+ + {/* Summary Stats */} +
+ setSelectedStatus('open')}> + +
+
+ +
+
+
Abiertas
+
{openCount}
+
+
+
+
+ + setSelectedStatus('won')}> + +
+
+ +
+
+
Ganadas
+
{wonCount}
+
+
+
+
+ + + +
+
+ +
+
+
Pipeline
+
${formatCurrency(totalPipeline)}
+
+
+
+
+ + + +
+
+ +
+
+
Ponderado
+
${formatCurrency(weightedPipeline)}
+
+
+
+
+ + + +
+
+ +
+
+
Ganado
+
${formatCurrency(wonRevenue)}
+
+
+
+
+
+ + + + Lista de Oportunidades + + +
+ {/* Filters */} +
+
+ + 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" + /> +
+ + + + {(selectedStatus || searchTerm) && ( + + )} +
+ + {/* Table */} + {opportunities.length === 0 && !isLoading ? ( + + ) : ( + + )} +
+
+
+ + {/* Win Opportunity Modal */} + 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 */} + 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" + /> +
+ ); +} + +export default OpportunitiesPage; diff --git a/src/pages/crm/index.ts b/src/pages/crm/index.ts new file mode 100644 index 0000000..d9a35fa --- /dev/null +++ b/src/pages/crm/index.ts @@ -0,0 +1,2 @@ +export { LeadsPage, default as LeadsPageDefault } from './LeadsPage'; +export { OpportunitiesPage, default as OpportunitiesPageDefault } from './OpportunitiesPage'; diff --git a/src/pages/projects/ProjectsPage.tsx b/src/pages/projects/ProjectsPage.tsx new file mode 100644 index 0000000..5ce9917 --- /dev/null +++ b/src/pages/projects/ProjectsPage.tsx @@ -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 = { + draft: 'Borrador', + active: 'Activo', + completed: 'Completado', + cancelled: 'Cancelado', + on_hold: 'En Espera', +}; + +const statusColors: Record = { + 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(''); + const [searchTerm, setSearchTerm] = useState(''); + const [projectToActivate, setProjectToActivate] = useState(null); + const [projectToComplete, setProjectToComplete] = useState(null); + const [projectToCancel, setProjectToCancel] = useState(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: , + onClick: () => console.log('View', project.id), + }, + ]; + + if (project.status === 'draft') { + items.push({ + key: 'activate', + label: 'Activar proyecto', + icon: , + onClick: () => setProjectToActivate(project), + }); + } + + if (project.status === 'active') { + items.push({ + key: 'complete', + label: 'Completar proyecto', + icon: , + onClick: () => setProjectToComplete(project), + }); + } + + if (project.status !== 'completed' && project.status !== 'cancelled') { + items.push({ + key: 'cancel', + label: 'Cancelar proyecto', + icon: , + danger: true, + onClick: () => setProjectToCancel(project), + }); + } + + return items; + }; + + const columns: Column[] = [ + { + key: 'name', + header: 'Proyecto', + render: (project) => ( +
+
+ +
+
+
{project.name}
+ {project.code && ( +
{project.code}
+ )} +
+
+ ), + }, + { + key: 'manager', + header: 'Responsable', + render: (project) => ( +
+ + {project.managerName || '-'} +
+ ), + }, + { + key: 'dates', + header: 'Fechas', + render: (project) => ( +
+ {project.dateStart && project.dateEnd ? ( +
+ + {formatDate(project.dateStart, 'short')} - {formatDate(project.dateEnd, 'short')} +
+ ) : ( + Sin fechas + )} +
+ ), + }, + { + 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 ( +
+
+
+
+ {completed}/{total} +
+ ); + }, + }, + { + key: 'hours', + header: 'Horas', + render: (project) => ( +
+ + + {project.totalHours || 0}h / {project.plannedHours || 0}h + +
+ ), + }, + { + key: 'status', + header: 'Estado', + render: (project) => ( + + {statusLabels[project.status]} + + ), + }, + { + key: 'actions', + header: '', + render: (project) => ( + + + + } + 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 ( +
+ +
+ ); + } + + return ( +
+ + +
+
+

Proyectos

+

+ Gestiona proyectos, tareas y seguimiento de tiempo +

+
+
+ + +
+
+ + {/* Summary Stats */} +
+ setSelectedStatus('draft')}> + +
+
+ +
+
+
Borradores
+
{draftCount}
+
+
+
+
+ + setSelectedStatus('active')}> + +
+
+ +
+
+
Activos
+
{activeCount}
+
+
+
+
+ + setSelectedStatus('completed')}> + +
+
+ +
+
+
Completados
+
{completedCount}
+
+
+
+
+ + + +
+
+ +
+
+
Tareas
+
{completedTasks}/{totalTasks}
+
+
+
+
+ + + +
+
+ +
+
+
Horas Totales
+
{formatNumber(totalHours, 'es-MX', { maximumFractionDigits: 1 })}h
+
+
+
+
+
+ + + + Lista de Proyectos + + +
+ {/* Filters */} +
+
+ + 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" + /> +
+ + + + {(selectedStatus || searchTerm) && ( + + )} +
+ + {/* Table */} + {projects.length === 0 && !isLoading ? ( + + ) : ( + + )} +
+
+
+ + {/* Activate Project Modal */} + setProjectToActivate(null)} + onConfirm={handleActivate} + title="Activar proyecto" + message={`¿Activar el proyecto "${projectToActivate?.name}"?`} + variant="info" + confirmText="Activar" + /> + + {/* Complete Project Modal */} + setProjectToComplete(null)} + onConfirm={handleComplete} + title="Completar proyecto" + message={`¿Marcar el proyecto "${projectToComplete?.name}" como completado?`} + variant="success" + confirmText="Completar" + /> + + {/* Cancel Project Modal */} + setProjectToCancel(null)} + onConfirm={handleCancel} + title="Cancelar proyecto" + message={`¿Cancelar el proyecto "${projectToCancel?.name}"? Esta accion no se puede deshacer.`} + variant="danger" + confirmText="Cancelar proyecto" + /> +
+ ); +} + +export default ProjectsPage; diff --git a/src/pages/projects/TasksPage.tsx b/src/pages/projects/TasksPage.tsx new file mode 100644 index 0000000..54c531c --- /dev/null +++ b/src/pages/projects/TasksPage.tsx @@ -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 = { + todo: 'Por Hacer', + in_progress: 'En Progreso', + review: 'En Revision', + done: 'Completada', + cancelled: 'Cancelada', +}; + +const statusColors: Record = { + 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 = { + low: 'Baja', + normal: 'Normal', + high: 'Alta', + urgent: 'Urgente', +}; + +const priorityColors: Record = { + low: 'text-gray-500', + normal: 'text-blue-500', + high: 'text-amber-500', + urgent: 'text-red-500', +}; + +export function TasksPage() { + const [selectedStatus, setSelectedStatus] = useState(''); + const [selectedPriority, setSelectedPriority] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [taskToStart, setTaskToStart] = useState(null); + const [taskToComplete, setTaskToComplete] = useState(null); + const [taskToCancel, setTaskToCancel] = useState(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: , + onClick: () => console.log('View', task.id), + }, + ]; + + if (task.status === 'todo') { + items.push({ + key: 'start', + label: 'Iniciar tarea', + icon: , + onClick: () => setTaskToStart(task), + }); + } + + if (task.status === 'in_progress' || task.status === 'review') { + items.push({ + key: 'complete', + label: 'Completar tarea', + icon: , + onClick: () => setTaskToComplete(task), + }); + } + + if (task.status !== 'done' && task.status !== 'cancelled') { + items.push({ + key: 'cancel', + label: 'Cancelar tarea', + icon: , + danger: true, + onClick: () => setTaskToCancel(task), + }); + } + + return items; + }; + + const columns: Column[] = [ + { + key: 'name', + header: 'Tarea', + render: (task) => ( +
+
+ +
+
+
{task.name}
+ {task.projectName && ( +
+ + {task.projectName} +
+ )} +
+
+ ), + }, + { + key: 'assignedTo', + header: 'Asignado', + render: (task) => ( +
+ + {task.assignedToName || 'Sin asignar'} +
+ ), + }, + { + key: 'priority', + header: 'Prioridad', + render: (task) => ( +
+ + + {priorityLabels[task.priority]} + +
+ ), + }, + { + key: 'deadline', + header: 'Fecha Limite', + sortable: true, + render: (task) => { + if (!task.dateDeadline) { + return Sin fecha; + } + const isOverdue = new Date(task.dateDeadline) < new Date() && task.status !== 'done'; + return ( +
+ + {formatDate(task.dateDeadline, 'short')} +
+ ); + }, + }, + { + key: 'hours', + header: 'Horas', + render: (task) => ( +
+ + + {task.spentHours || 0}h / {task.estimatedHours || 0}h + +
+ ), + }, + { + key: 'status', + header: 'Estado', + render: (task) => ( + + {statusLabels[task.status]} + + ), + }, + { + key: 'actions', + header: '', + render: (task) => ( + + + + } + 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 ( +
+ +
+ ); + } + + return ( +
+ + +
+
+

Tareas

+

+ Gestiona y da seguimiento a todas las tareas de proyectos +

+
+
+ + +
+
+ + {/* Summary Stats */} +
+ setSelectedStatus('todo')}> + +
+
+ +
+
+
Por Hacer
+
{todoCount}
+
+
+
+
+ + setSelectedStatus('in_progress')}> + +
+
+ +
+
+
En Progreso
+
{inProgressCount}
+
+
+
+
+ + setSelectedStatus('done')}> + +
+
+ +
+
+
Completadas
+
{doneCount}
+
+
+
+
+ + setSelectedPriority('urgent')}> + +
+
+ +
+
+
Urgentes
+
{urgentCount}
+
+
+
+
+ + + +
+
+ +
+
+
Horas
+
+ {formatNumber(totalSpent, 'es-MX', { maximumFractionDigits: 1 })}/ + {formatNumber(totalEstimated, 'es-MX', { maximumFractionDigits: 1 })}h +
+
+
+
+
+
+ + + + Lista de Tareas + + +
+ {/* Filters */} +
+
+ + 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" + /> +
+ + + + + + {(selectedStatus || selectedPriority || searchTerm) && ( + + )} +
+ + {/* Table */} + {tasks.length === 0 && !isLoading ? ( + + ) : ( + + )} +
+
+
+ + {/* Start Task Modal */} + setTaskToStart(null)} + onConfirm={handleStart} + title="Iniciar tarea" + message={`¿Iniciar la tarea "${taskToStart?.name}"?`} + variant="info" + confirmText="Iniciar" + /> + + {/* Complete Task Modal */} + setTaskToComplete(null)} + onConfirm={handleComplete} + title="Completar tarea" + message={`¿Marcar la tarea "${taskToComplete?.name}" como completada?`} + variant="success" + confirmText="Completar" + /> + + {/* Cancel Task Modal */} + setTaskToCancel(null)} + onConfirm={handleCancel} + title="Cancelar tarea" + message={`¿Cancelar la tarea "${taskToCancel?.name}"? Esta accion no se puede deshacer.`} + variant="danger" + confirmText="Cancelar tarea" + /> +
+ ); +} + +export default TasksPage; diff --git a/src/pages/projects/index.ts b/src/pages/projects/index.ts new file mode 100644 index 0000000..493a947 --- /dev/null +++ b/src/pages/projects/index.ts @@ -0,0 +1,2 @@ +export { ProjectsPage, default as ProjectsPageDefault } from './ProjectsPage'; +export { TasksPage, default as TasksPageDefault } from './TasksPage';