feat(reporting): enhance time reports page with new filters and components
- Added new components for filtering by billable status, categories, projects, members, and teams in the time reports overview. - Implemented a new header component to manage the layout and functionality of the time reports page. - Refactored existing components to improve organization and maintainability, including the removal of deprecated files. - Updated localization files to support new UI elements and ensure consistency across languages. - Adjusted the language selector to reflect the correct language codes for Chinese.
This commit is contained in:
@@ -4,7 +4,9 @@
|
|||||||
"Bash(find:*)",
|
"Bash(find:*)",
|
||||||
"Bash(npm run build:*)",
|
"Bash(npm run build:*)",
|
||||||
"Bash(npm run type-check:*)",
|
"Bash(npm run type-check:*)",
|
||||||
"Bash(npm run:*)"
|
"Bash(npm run:*)",
|
||||||
|
"Bash(mkdir:*)",
|
||||||
|
"Bash(cp:*)"
|
||||||
],
|
],
|
||||||
"deny": []
|
"deny": []
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -15,5 +15,15 @@
|
|||||||
"assignToMe": "分配给我",
|
"assignToMe": "分配给我",
|
||||||
"archive": "归档",
|
"archive": "归档",
|
||||||
"newTaskNamePlaceholder": "写一个任务名称",
|
"newTaskNamePlaceholder": "写一个任务名称",
|
||||||
"newSubtaskNamePlaceholder": "写一个子任务名称"
|
"newSubtaskNamePlaceholder": "写一个子任务名称",
|
||||||
|
"untitledSection": "无标题部分",
|
||||||
|
"unmapped": "未映射",
|
||||||
|
"clickToChangeDate": "点击更改日期",
|
||||||
|
"noDueDate": "无截止日期",
|
||||||
|
"save": "保存",
|
||||||
|
"clear": "清除",
|
||||||
|
"nextWeek": "下周",
|
||||||
|
"noSubtasks": "无子任务",
|
||||||
|
"showSubtasks": "显示子任务",
|
||||||
|
"hideSubtasks": "隐藏子任务"
|
||||||
}
|
}
|
||||||
@@ -18,6 +18,10 @@
|
|||||||
"changeCategory": "更改类别",
|
"changeCategory": "更改类别",
|
||||||
"clickToEditGroupName": "点击编辑组名称",
|
"clickToEditGroupName": "点击编辑组名称",
|
||||||
"enterGroupName": "输入组名称",
|
"enterGroupName": "输入组名称",
|
||||||
|
"todo": "待办",
|
||||||
|
"inProgress": "进行中",
|
||||||
|
"done": "已完成",
|
||||||
|
"defaultTaskName": "无标题任务",
|
||||||
|
|
||||||
"indicators": {
|
"indicators": {
|
||||||
"tooltips": {
|
"tooltips": {
|
||||||
|
|||||||
@@ -29,5 +29,22 @@
|
|||||||
"noCategory": "无类别",
|
"noCategory": "无类别",
|
||||||
"noProjects": "未找到项目",
|
"noProjects": "未找到项目",
|
||||||
"noTeams": "未找到团队",
|
"noTeams": "未找到团队",
|
||||||
"noData": "未找到数据"
|
"noData": "未找到数据",
|
||||||
|
"groupBy": "分组方式",
|
||||||
|
"groupByCategory": "类别",
|
||||||
|
"groupByTeam": "团队",
|
||||||
|
"groupByStatus": "状态",
|
||||||
|
"groupByNone": "无",
|
||||||
|
"clearSearch": "清除搜索",
|
||||||
|
"selectedProjects": "已选项目",
|
||||||
|
"projectsSelected": "个项目已选择",
|
||||||
|
"showSelected": "仅显示已选择",
|
||||||
|
"expandAll": "全部展开",
|
||||||
|
"collapseAll": "全部折叠",
|
||||||
|
"ungrouped": "未分组",
|
||||||
|
"clearAll": "清除全部",
|
||||||
|
"filterByBillableStatus": "按计费状态筛选",
|
||||||
|
"searchByMember": "按成员搜索",
|
||||||
|
"members": "成员",
|
||||||
|
"utilization": "利用率"
|
||||||
}
|
}
|
||||||
@@ -26,5 +26,8 @@
|
|||||||
"noDueDate": "Pa datë përfundimi",
|
"noDueDate": "Pa datë përfundimi",
|
||||||
"save": "Ruaj",
|
"save": "Ruaj",
|
||||||
"clear": "Pastro",
|
"clear": "Pastro",
|
||||||
"nextWeek": "Javën e ardhshme"
|
"nextWeek": "Javën e ardhshme",
|
||||||
|
"noSubtasks": "Pa nëndetyra",
|
||||||
|
"showSubtasks": "Shfaq nëndetyrat",
|
||||||
|
"hideSubtasks": "Fshih nëndetyrat"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,5 +38,13 @@
|
|||||||
"createClient": "Krijo klient",
|
"createClient": "Krijo klient",
|
||||||
"searchInputPlaceholder": "Kërko sipas emrit ose emailit",
|
"searchInputPlaceholder": "Kërko sipas emrit ose emailit",
|
||||||
"hoursPerDayValidationMessage": "Orët në ditë duhet të jenë një numër midis 1 dhe 24",
|
"hoursPerDayValidationMessage": "Orët në ditë duhet të jenë një numër midis 1 dhe 24",
|
||||||
"noPermission": "Nuk ka leje"
|
"workingDaysValidationMessage": "Ditët e punës duhet të jenë një numër pozitiv",
|
||||||
|
"manDaysValidationMessage": "Ditët e punëtorëve duhet të jenë një numër pozitiv",
|
||||||
|
"noPermission": "Nuk ka leje",
|
||||||
|
"progressSettings": "Cilësimet e Progresit",
|
||||||
|
"manualProgress": "Progresi Manual",
|
||||||
|
"manualProgressTooltip": "Lejo përditësimet manuale të progresit për detyrat pa nëndetyra",
|
||||||
|
"weightedProgress": "Progresi i Ponderuar",
|
||||||
|
"weightedProgressTooltip": "Llogarit progresin bazuar në peshat e nëndetyrave",
|
||||||
|
"timeProgress": "Progresi i Bazuar në Kohë"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"recurring": "Përsëritës",
|
||||||
|
"recurringTaskConfiguration": "Konfigurimi i detyrës përsëritëse",
|
||||||
|
"repeats": "Përsëritet",
|
||||||
|
"daily": "Ditore",
|
||||||
|
"weekly": "Javore",
|
||||||
|
"everyXDays": "Çdo X ditë",
|
||||||
|
"everyXWeeks": "Çdo X javë",
|
||||||
|
"everyXMonths": "Çdo X muaj",
|
||||||
|
"monthly": "Mujore",
|
||||||
|
"selectDaysOfWeek": "Zgjidh ditët e javës",
|
||||||
|
"mon": "Hën",
|
||||||
|
"tue": "Mar",
|
||||||
|
"wed": "Mër",
|
||||||
|
"thu": "Enj",
|
||||||
|
"fri": "Pre",
|
||||||
|
"sat": "Sht",
|
||||||
|
"sun": "Die",
|
||||||
|
"monthlyRepeatType": "Lloji i përsëritjes mujore",
|
||||||
|
"onSpecificDate": "Në një datë specifike",
|
||||||
|
"onSpecificDay": "Në një ditë specifike",
|
||||||
|
"dateOfMonth": "Data e muajit",
|
||||||
|
"weekOfMonth": "Java e muajit",
|
||||||
|
"dayOfWeek": "Dita e javës",
|
||||||
|
"first": "E para",
|
||||||
|
"second": "E dyta",
|
||||||
|
"third": "E treta",
|
||||||
|
"fourth": "E katërta",
|
||||||
|
"last": "E fundit",
|
||||||
|
"intervalDays": "Intervali (ditë)",
|
||||||
|
"intervalWeeks": "Intervali (javë)",
|
||||||
|
"intervalMonths": "Intervali (muaj)",
|
||||||
|
"saveChanges": "Ruaj ndryshimet"
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
"searchByName": "Kërko sipas emrit",
|
"searchByName": "Kërko sipas emrit",
|
||||||
"selectAll": "Zgjidh të Gjitha",
|
"selectAll": "Zgjidh të Gjitha",
|
||||||
|
"clearAll": "Pastro të Gjitha",
|
||||||
"teams": "Ekipet",
|
"teams": "Ekipet",
|
||||||
|
|
||||||
"searchByProject": "Kërko sipas emrit të projektit",
|
"searchByProject": "Kërko sipas emrit të projektit",
|
||||||
@@ -15,6 +16,8 @@
|
|||||||
|
|
||||||
"billable": "Fakturueshme",
|
"billable": "Fakturueshme",
|
||||||
"nonBillable": "Jo Fakturueshme",
|
"nonBillable": "Jo Fakturueshme",
|
||||||
|
"allBillableTypes": "Të Gjitha Llojet e Fakturueshme",
|
||||||
|
"filterByBillableStatus": "Filtro sipas statusit të fakturueshmërisë",
|
||||||
|
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
|
|
||||||
@@ -28,6 +31,9 @@
|
|||||||
|
|
||||||
"membersTimeSheet": "Fletë Kohore e Anëtarëve",
|
"membersTimeSheet": "Fletë Kohore e Anëtarëve",
|
||||||
"member": "Anëtar",
|
"member": "Anëtar",
|
||||||
|
"members": "Anëtarët",
|
||||||
|
"searchByMember": "Kërko sipas anëtarit",
|
||||||
|
"utilization": "Përdorimi",
|
||||||
|
|
||||||
"estimatedVsActual": "Vlerësuar vs Aktual",
|
"estimatedVsActual": "Vlerësuar vs Aktual",
|
||||||
"workingDays": "Ditë Pune",
|
"workingDays": "Ditë Pune",
|
||||||
@@ -40,5 +46,17 @@
|
|||||||
"noCategory": "Pa Kategori",
|
"noCategory": "Pa Kategori",
|
||||||
"noProjects": "Nuk u gjetën projekte",
|
"noProjects": "Nuk u gjetën projekte",
|
||||||
"noTeams": "Nuk u gjetën ekipe",
|
"noTeams": "Nuk u gjetën ekipe",
|
||||||
"noData": "Nuk u gjetën të dhëna"
|
"noData": "Nuk u gjetën të dhëna",
|
||||||
|
"groupBy": "Gruppo sipas",
|
||||||
|
"groupByCategory": "Kategori",
|
||||||
|
"groupByTeam": "Ekip",
|
||||||
|
"groupByStatus": "Status",
|
||||||
|
"groupByNone": "Asnjë",
|
||||||
|
"clearSearch": "Pastro kërkimin",
|
||||||
|
"selectedProjects": "Projektet e Zgjedhura",
|
||||||
|
"projectsSelected": "projekte të zgjedhura",
|
||||||
|
"showSelected": "Shfaq Vetëm të Zgjedhurat",
|
||||||
|
"expandAll": "Zgjero të Gjitha",
|
||||||
|
"collapseAll": "Mbyll të Gjitha",
|
||||||
|
"ungrouped": "Pa Grupuar"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,5 +38,13 @@
|
|||||||
"createClient": "Kunde erstellen",
|
"createClient": "Kunde erstellen",
|
||||||
"searchInputPlaceholder": "Nach Name oder E-Mail suchen",
|
"searchInputPlaceholder": "Nach Name oder E-Mail suchen",
|
||||||
"hoursPerDayValidationMessage": "Stunden pro Tag müssen zwischen 1 und 24",
|
"hoursPerDayValidationMessage": "Stunden pro Tag müssen zwischen 1 und 24",
|
||||||
"noPermission": "Keine Berechtigung"
|
"workingDaysValidationMessage": "Arbeitstage müssen eine positive Zahl sein",
|
||||||
|
"manDaysValidationMessage": "Personentage müssen eine positive Zahl sein",
|
||||||
|
"noPermission": "Keine Berechtigung",
|
||||||
|
"progressSettings": "Fortschrittseinstellungen",
|
||||||
|
"manualProgress": "Manueller Fortschritt",
|
||||||
|
"manualProgressTooltip": "Manuelle Fortschrittsaktualisierungen für Aufgaben ohne Unteraufgaben erlauben",
|
||||||
|
"weightedProgress": "Gewichteter Fortschritt",
|
||||||
|
"weightedProgressTooltip": "Fortschritt basierend auf Unteraufgaben-Gewichten berechnen",
|
||||||
|
"timeProgress": "Zeitbasierter Fortschritt"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"recurring": "Wiederkehrend",
|
||||||
|
"recurringTaskConfiguration": "Wiederkehrende Aufgabenkonfiguration",
|
||||||
|
"repeats": "Wiederholt sich",
|
||||||
|
"daily": "Täglich",
|
||||||
|
"weekly": "Wöchentlich",
|
||||||
|
"everyXDays": "Alle X Tage",
|
||||||
|
"everyXWeeks": "Alle X Wochen",
|
||||||
|
"everyXMonths": "Alle X Monate",
|
||||||
|
"monthly": "Monatlich",
|
||||||
|
"selectDaysOfWeek": "Wochentage auswählen",
|
||||||
|
"mon": "Mo",
|
||||||
|
"tue": "Di",
|
||||||
|
"wed": "Mi",
|
||||||
|
"thu": "Do",
|
||||||
|
"fri": "Fr",
|
||||||
|
"sat": "Sa",
|
||||||
|
"sun": "So",
|
||||||
|
"monthlyRepeatType": "Monatlicher Wiederholungstyp",
|
||||||
|
"onSpecificDate": "An einem bestimmten Datum",
|
||||||
|
"onSpecificDay": "An einem bestimmten Tag",
|
||||||
|
"dateOfMonth": "Datum des Monats",
|
||||||
|
"weekOfMonth": "Woche des Monats",
|
||||||
|
"dayOfWeek": "Wochentag",
|
||||||
|
"first": "Erste",
|
||||||
|
"second": "Zweite",
|
||||||
|
"third": "Dritte",
|
||||||
|
"fourth": "Vierte",
|
||||||
|
"last": "Letzte",
|
||||||
|
"intervalDays": "Intervall (Tage)",
|
||||||
|
"intervalWeeks": "Intervall (Wochen)",
|
||||||
|
"intervalMonths": "Intervall (Monate)",
|
||||||
|
"saveChanges": "Änderungen speichern"
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
"searchByName": "Nach Namen suchen",
|
"searchByName": "Nach Namen suchen",
|
||||||
"selectAll": "Alle auswählen",
|
"selectAll": "Alle auswählen",
|
||||||
|
"clearAll": "Alle löschen",
|
||||||
"teams": "Teams",
|
"teams": "Teams",
|
||||||
|
|
||||||
"searchByProject": "Nach Projektnamen suchen",
|
"searchByProject": "Nach Projektnamen suchen",
|
||||||
@@ -15,6 +16,8 @@
|
|||||||
|
|
||||||
"billable": "Abrechenbar",
|
"billable": "Abrechenbar",
|
||||||
"nonBillable": "Nicht abrechenbar",
|
"nonBillable": "Nicht abrechenbar",
|
||||||
|
"allBillableTypes": "Alle Abrechnungsarten",
|
||||||
|
"filterByBillableStatus": "Nach abrechenbarem Status filtern",
|
||||||
|
|
||||||
"total": "Gesamt",
|
"total": "Gesamt",
|
||||||
|
|
||||||
@@ -28,6 +31,9 @@
|
|||||||
|
|
||||||
"membersTimeSheet": "Mitglieder-Zeiterfassung",
|
"membersTimeSheet": "Mitglieder-Zeiterfassung",
|
||||||
"member": "Mitglied",
|
"member": "Mitglied",
|
||||||
|
"members": "Mitglieder",
|
||||||
|
"searchByMember": "Nach Mitglied suchen",
|
||||||
|
"utilization": "Auslastung",
|
||||||
|
|
||||||
"estimatedVsActual": "Geschätzt vs. Tatsächlich",
|
"estimatedVsActual": "Geschätzt vs. Tatsächlich",
|
||||||
"workingDays": "Arbeitstage",
|
"workingDays": "Arbeitstage",
|
||||||
@@ -40,5 +46,17 @@
|
|||||||
"noCategory": "Keine Kategorie",
|
"noCategory": "Keine Kategorie",
|
||||||
"noProjects": "Keine Projekte gefunden",
|
"noProjects": "Keine Projekte gefunden",
|
||||||
"noTeams": "Keine Teams gefunden",
|
"noTeams": "Keine Teams gefunden",
|
||||||
"noData": "Keine Daten gefunden"
|
"noData": "Keine Daten gefunden",
|
||||||
|
"groupBy": "Gruppieren nach",
|
||||||
|
"groupByCategory": "Kategorie",
|
||||||
|
"groupByTeam": "Team",
|
||||||
|
"groupByStatus": "Status",
|
||||||
|
"groupByNone": "Keine",
|
||||||
|
"clearSearch": "Suche löschen",
|
||||||
|
"selectedProjects": "Ausgewählte Projekte",
|
||||||
|
"projectsSelected": "Projekte ausgewählt",
|
||||||
|
"showSelected": "Nur Ausgewählte anzeigen",
|
||||||
|
"expandAll": "Alle erweitern",
|
||||||
|
"collapseAll": "Alle einklappen",
|
||||||
|
"ungrouped": "Nicht gruppiert"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
"searchByName": "Search by name",
|
"searchByName": "Search by name",
|
||||||
"selectAll": "Select All",
|
"selectAll": "Select All",
|
||||||
|
"clearAll": "Clear All",
|
||||||
"teams": "Teams",
|
"teams": "Teams",
|
||||||
|
|
||||||
"searchByProject": "Search by project name",
|
"searchByProject": "Search by project name",
|
||||||
@@ -15,6 +16,8 @@
|
|||||||
|
|
||||||
"billable": "Billable",
|
"billable": "Billable",
|
||||||
"nonBillable": "Non Billable",
|
"nonBillable": "Non Billable",
|
||||||
|
"allBillableTypes": "All Billable Types",
|
||||||
|
"filterByBillableStatus": "Filter by billable status",
|
||||||
|
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
|
|
||||||
@@ -28,6 +31,9 @@
|
|||||||
|
|
||||||
"membersTimeSheet": "Members Time Sheet",
|
"membersTimeSheet": "Members Time Sheet",
|
||||||
"member": "Member",
|
"member": "Member",
|
||||||
|
"members": "Members",
|
||||||
|
"searchByMember": "Search by member",
|
||||||
|
"utilization": "Utilization",
|
||||||
|
|
||||||
"estimatedVsActual": "Estimated vs Actual",
|
"estimatedVsActual": "Estimated vs Actual",
|
||||||
"workingDays": "Working Days",
|
"workingDays": "Working Days",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
"searchByName": "Buscar por nombre",
|
"searchByName": "Buscar por nombre",
|
||||||
"selectAll": "Seleccionar Todo",
|
"selectAll": "Seleccionar Todo",
|
||||||
|
"clearAll": "Limpiar Todo",
|
||||||
"teams": "Equipos",
|
"teams": "Equipos",
|
||||||
|
|
||||||
"searchByProject": "Buscar por nombre del proyecto",
|
"searchByProject": "Buscar por nombre del proyecto",
|
||||||
@@ -15,6 +16,8 @@
|
|||||||
|
|
||||||
"billable": "Facturable",
|
"billable": "Facturable",
|
||||||
"nonBillable": "No Facturable",
|
"nonBillable": "No Facturable",
|
||||||
|
"allBillableTypes": "Todos los Tipos Facturables",
|
||||||
|
"filterByBillableStatus": "Filtrar por estado facturable",
|
||||||
|
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
|
|
||||||
@@ -28,6 +31,9 @@
|
|||||||
|
|
||||||
"membersTimeSheet": "Hoja de Tiempo de Miembros",
|
"membersTimeSheet": "Hoja de Tiempo de Miembros",
|
||||||
"member": "Miembro",
|
"member": "Miembro",
|
||||||
|
"members": "Miembros",
|
||||||
|
"searchByMember": "Buscar por miembro",
|
||||||
|
"utilization": "Utilización",
|
||||||
|
|
||||||
"estimatedVsActual": "Estimado vs Real",
|
"estimatedVsActual": "Estimado vs Real",
|
||||||
"workingDays": "Días Laborables",
|
"workingDays": "Días Laborables",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
"searchByName": "Pesquisar por nome",
|
"searchByName": "Pesquisar por nome",
|
||||||
"selectAll": "Selecionar Tudo",
|
"selectAll": "Selecionar Tudo",
|
||||||
|
"clearAll": "Limpar Tudo",
|
||||||
"teams": "Equipes",
|
"teams": "Equipes",
|
||||||
|
|
||||||
"searchByProject": "Pesquisar por nome do projeto",
|
"searchByProject": "Pesquisar por nome do projeto",
|
||||||
@@ -15,6 +16,8 @@
|
|||||||
|
|
||||||
"billable": "Faturável",
|
"billable": "Faturável",
|
||||||
"nonBillable": "Não Faturável",
|
"nonBillable": "Não Faturável",
|
||||||
|
"allBillableTypes": "Todos os Tipos Faturáveis",
|
||||||
|
"filterByBillableStatus": "Filtrar por status faturável",
|
||||||
|
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
|
|
||||||
@@ -28,6 +31,9 @@
|
|||||||
|
|
||||||
"membersTimeSheet": "Folha de Tempo de Membros",
|
"membersTimeSheet": "Folha de Tempo de Membros",
|
||||||
"member": "Membro",
|
"member": "Membro",
|
||||||
|
"members": "Membros",
|
||||||
|
"searchByMember": "Pesquisar por membro",
|
||||||
|
"utilization": "Utilização",
|
||||||
|
|
||||||
"estimatedVsActual": "Estimado vs Real",
|
"estimatedVsActual": "Estimado vs Real",
|
||||||
"workingDays": "Dias Úteis",
|
"workingDays": "Dias Úteis",
|
||||||
|
|||||||
@@ -15,5 +15,15 @@
|
|||||||
"assignToMe": "分配给我",
|
"assignToMe": "分配给我",
|
||||||
"archive": "归档",
|
"archive": "归档",
|
||||||
"newTaskNamePlaceholder": "写一个任务名称",
|
"newTaskNamePlaceholder": "写一个任务名称",
|
||||||
"newSubtaskNamePlaceholder": "写一个子任务名称"
|
"newSubtaskNamePlaceholder": "写一个子任务名称",
|
||||||
|
"untitledSection": "无标题部分",
|
||||||
|
"unmapped": "未映射",
|
||||||
|
"clickToChangeDate": "点击更改日期",
|
||||||
|
"noDueDate": "无截止日期",
|
||||||
|
"save": "保存",
|
||||||
|
"clear": "清除",
|
||||||
|
"nextWeek": "下周",
|
||||||
|
"noSubtasks": "无子任务",
|
||||||
|
"showSubtasks": "显示子任务",
|
||||||
|
"hideSubtasks": "隐藏子任务"
|
||||||
}
|
}
|
||||||
@@ -38,5 +38,13 @@
|
|||||||
"createClient": "创建客户",
|
"createClient": "创建客户",
|
||||||
"searchInputPlaceholder": "按名称或电子邮件搜索",
|
"searchInputPlaceholder": "按名称或电子邮件搜索",
|
||||||
"hoursPerDayValidationMessage": "每天小时数必须是1到24之间的数字",
|
"hoursPerDayValidationMessage": "每天小时数必须是1到24之间的数字",
|
||||||
"noPermission": "无权限"
|
"workingDaysValidationMessage": "工作日必须是正数",
|
||||||
|
"manDaysValidationMessage": "人天必须是正数",
|
||||||
|
"noPermission": "无权限",
|
||||||
|
"progressSettings": "进度设置",
|
||||||
|
"manualProgress": "手动进度",
|
||||||
|
"manualProgressTooltip": "允许对没有子任务的任务进行手动进度更新",
|
||||||
|
"weightedProgress": "加权进度",
|
||||||
|
"weightedProgressTooltip": "基于子任务权重计算进度",
|
||||||
|
"timeProgress": "基于时间的进度"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"recurring": "重复",
|
||||||
|
"recurringTaskConfiguration": "重复任务配置",
|
||||||
|
"repeats": "重复",
|
||||||
|
"daily": "每日",
|
||||||
|
"weekly": "每周",
|
||||||
|
"everyXDays": "每X天",
|
||||||
|
"everyXWeeks": "每X周",
|
||||||
|
"everyXMonths": "每X月",
|
||||||
|
"monthly": "每月",
|
||||||
|
"selectDaysOfWeek": "选择星期几",
|
||||||
|
"mon": "周一",
|
||||||
|
"tue": "周二",
|
||||||
|
"wed": "周三",
|
||||||
|
"thu": "周四",
|
||||||
|
"fri": "周五",
|
||||||
|
"sat": "周六",
|
||||||
|
"sun": "周日",
|
||||||
|
"monthlyRepeatType": "每月重复类型",
|
||||||
|
"onSpecificDate": "在特定日期",
|
||||||
|
"onSpecificDay": "在特定星期几",
|
||||||
|
"dateOfMonth": "月份日期",
|
||||||
|
"weekOfMonth": "月份周数",
|
||||||
|
"dayOfWeek": "星期几",
|
||||||
|
"first": "第一",
|
||||||
|
"second": "第二",
|
||||||
|
"third": "第三",
|
||||||
|
"fourth": "第四",
|
||||||
|
"last": "最后",
|
||||||
|
"intervalDays": "间隔(天)",
|
||||||
|
"intervalWeeks": "间隔(周)",
|
||||||
|
"intervalMonths": "间隔(月)",
|
||||||
|
"saveChanges": "保存更改"
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
"timeSheet": "时间表",
|
"timeSheet": "时间表",
|
||||||
"searchByName": "按名称搜索",
|
"searchByName": "按名称搜索",
|
||||||
"selectAll": "全选",
|
"selectAll": "全选",
|
||||||
|
"clearAll": "清除全部",
|
||||||
"teams": "团队",
|
"teams": "团队",
|
||||||
"searchByProject": "按项目名称搜索",
|
"searchByProject": "按项目名称搜索",
|
||||||
"projects": "项目",
|
"projects": "项目",
|
||||||
@@ -11,6 +12,8 @@
|
|||||||
"categories": "类别",
|
"categories": "类别",
|
||||||
"billable": "可计费",
|
"billable": "可计费",
|
||||||
"nonBillable": "不可计费",
|
"nonBillable": "不可计费",
|
||||||
|
"allBillableTypes": "所有计费类型",
|
||||||
|
"filterByBillableStatus": "按计费状态筛选",
|
||||||
"total": "总计",
|
"total": "总计",
|
||||||
"projectsTimeSheet": "项目时间表",
|
"projectsTimeSheet": "项目时间表",
|
||||||
"loggedTime": "已记录时间(小时)",
|
"loggedTime": "已记录时间(小时)",
|
||||||
@@ -19,6 +22,9 @@
|
|||||||
"for": "为",
|
"for": "为",
|
||||||
"membersTimeSheet": "成员时间表",
|
"membersTimeSheet": "成员时间表",
|
||||||
"member": "成员",
|
"member": "成员",
|
||||||
|
"members": "成员",
|
||||||
|
"searchByMember": "按成员搜索",
|
||||||
|
"utilization": "利用率",
|
||||||
"estimatedVsActual": "预计用时 vs 实际用时",
|
"estimatedVsActual": "预计用时 vs 实际用时",
|
||||||
"workingDays": "工作日",
|
"workingDays": "工作日",
|
||||||
"manDays": "人天",
|
"manDays": "人天",
|
||||||
@@ -29,5 +35,17 @@
|
|||||||
"noCategory": "无类别",
|
"noCategory": "无类别",
|
||||||
"noProjects": "未找到项目",
|
"noProjects": "未找到项目",
|
||||||
"noTeams": "未找到团队",
|
"noTeams": "未找到团队",
|
||||||
"noData": "未找到数据"
|
"noData": "未找到数据",
|
||||||
|
"groupBy": "分组方式",
|
||||||
|
"groupByCategory": "类别",
|
||||||
|
"groupByTeam": "团队",
|
||||||
|
"groupByStatus": "状态",
|
||||||
|
"groupByNone": "无",
|
||||||
|
"clearSearch": "清除搜索",
|
||||||
|
"selectedProjects": "已选项目",
|
||||||
|
"projectsSelected": "个项目已选择",
|
||||||
|
"showSelected": "仅显示已选择",
|
||||||
|
"expandAll": "全部展开",
|
||||||
|
"collapseAll": "全部折叠",
|
||||||
|
"ungrouped": "未分组"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import { setSelectOrDeselectBillable } from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Dropdown,
|
||||||
|
MenuProps,
|
||||||
|
Space,
|
||||||
|
Divider,
|
||||||
|
theme,
|
||||||
|
CaretDownFilled,
|
||||||
|
FilterOutlined,
|
||||||
|
CheckCircleFilled,
|
||||||
|
} from '@/shared/antd-imports';
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const Billable: React.FC = () => {
|
||||||
|
const { t } = useTranslation('time-report');
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
const { billable } = useAppSelector(state => state.timeReportsOverviewReducer);
|
||||||
|
|
||||||
|
// Calculate active filters count
|
||||||
|
const activeFiltersCount = useMemo(() => {
|
||||||
|
let count = 0;
|
||||||
|
if (billable.billable) count++;
|
||||||
|
if (billable.nonBillable) count++;
|
||||||
|
return count;
|
||||||
|
}, [billable.billable, billable.nonBillable]);
|
||||||
|
|
||||||
|
// Check if all options are selected
|
||||||
|
const isAllSelected = billable.billable && billable.nonBillable;
|
||||||
|
const isNoneSelected = !billable.billable && !billable.nonBillable;
|
||||||
|
|
||||||
|
// Handle select all
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
dispatch(
|
||||||
|
setSelectOrDeselectBillable({
|
||||||
|
billable: true,
|
||||||
|
nonBillable: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle clear all
|
||||||
|
const handleClearAll = () => {
|
||||||
|
dispatch(
|
||||||
|
setSelectOrDeselectBillable({
|
||||||
|
billable: false,
|
||||||
|
nonBillable: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Theme-aware colors matching improved task filters
|
||||||
|
const isDark = token.colorBgContainer !== '#ffffff';
|
||||||
|
const colors = {
|
||||||
|
headerText: isDark ? '#8c8c8c' : '#595959',
|
||||||
|
borderColor: isDark ? '#404040' : '#f0f0f0',
|
||||||
|
linkActive: isDark ? '#d9d9d9' : '#1890ff',
|
||||||
|
linkDisabled: isDark ? '#8c8c8c' : '#d9d9d9',
|
||||||
|
successColor: isDark ? '#52c41a' : '#52c41a',
|
||||||
|
errorColor: isDark ? '#ff4d4f' : '#ff4d4f',
|
||||||
|
buttonBorder: isDark ? '#303030' : '#d9d9d9',
|
||||||
|
buttonText: activeFiltersCount > 0
|
||||||
|
? (isDark ? 'white' : '#262626')
|
||||||
|
: (isDark ? '#d9d9d9' : '#595959'),
|
||||||
|
buttonBg: activeFiltersCount > 0
|
||||||
|
? (isDark ? '#434343' : '#f5f5f5')
|
||||||
|
: (isDark ? '#141414' : 'white'),
|
||||||
|
dropdownBg: isDark ? '#1f1f1f' : 'white',
|
||||||
|
dropdownBorder: isDark ? '#303030' : '#d9d9d9',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dropdown items for the menu
|
||||||
|
const menuItems: MenuProps['items'] = [
|
||||||
|
{
|
||||||
|
key: 'header',
|
||||||
|
label: (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '4px 4px 2px',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '12px',
|
||||||
|
color: colors.headerText,
|
||||||
|
borderBottom: `1px solid ${colors.borderColor}`,
|
||||||
|
marginBottom: '2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('filterByBillableStatus')}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
label: (
|
||||||
|
<div style={{ padding: '2px 4px', marginBottom: '2px' }}>
|
||||||
|
<Space size="small">
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={handleSelectAll}
|
||||||
|
disabled={isAllSelected}
|
||||||
|
style={{
|
||||||
|
padding: '0 2px',
|
||||||
|
height: 'auto',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: isAllSelected ? colors.linkDisabled : colors.linkActive,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('selectAll')}
|
||||||
|
</Button>
|
||||||
|
<Divider type="vertical" style={{ margin: '0 2px' }} />
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={handleClearAll}
|
||||||
|
disabled={isNoneSelected}
|
||||||
|
style={{
|
||||||
|
padding: '0 2px',
|
||||||
|
height: 'auto',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: isNoneSelected ? colors.linkDisabled : colors.errorColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('clearAll')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'billable',
|
||||||
|
label: (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '4px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox checked={billable.billable} style={{ fontSize: '14px' }}>
|
||||||
|
<span style={{ marginLeft: '2px', fontSize: '14px' }}>{t('billable')}</span>
|
||||||
|
</Checkbox>
|
||||||
|
{billable.billable && (
|
||||||
|
<CheckCircleFilled style={{ color: colors.successColor, fontSize: '10px' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
onClick: () => {
|
||||||
|
dispatch(setSelectOrDeselectBillable({ ...billable, billable: !billable.billable }));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'nonBillable',
|
||||||
|
label: (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '4px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox checked={billable.nonBillable} style={{ fontSize: '14px' }}>
|
||||||
|
<span style={{ marginLeft: '2px', fontSize: '14px' }}>{t('nonBillable')}</span>
|
||||||
|
</Checkbox>
|
||||||
|
{billable.nonBillable && (
|
||||||
|
<CheckCircleFilled style={{ color: colors.successColor, fontSize: '10px' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
onClick: () => {
|
||||||
|
dispatch(setSelectOrDeselectBillable({ ...billable, nonBillable: !billable.nonBillable }));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Button text based on selection state
|
||||||
|
const getButtonText = () => {
|
||||||
|
if (isNoneSelected) return t('billable');
|
||||||
|
if (isAllSelected) return t('allBillableTypes');
|
||||||
|
if (billable.billable && !billable.nonBillable) return t('billable');
|
||||||
|
if (!billable.billable && billable.nonBillable) return t('nonBillable');
|
||||||
|
return t('billable');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Dropdown
|
||||||
|
menu={{ items: menuItems }}
|
||||||
|
placement="bottomLeft"
|
||||||
|
trigger={['click']}
|
||||||
|
overlayStyle={{
|
||||||
|
maxHeight: '330px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
boxShadow: isDark
|
||||||
|
? '0 6px 16px 0 rgba(0, 0, 0, 0.32), 0 3px 6px -4px rgba(0, 0, 0, 0.32), 0 9px 28px 8px rgba(0, 0, 0, 0.20)'
|
||||||
|
: '0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: `1px solid ${colors.dropdownBorder}`,
|
||||||
|
backgroundColor: colors.dropdownBg,
|
||||||
|
}}
|
||||||
|
overlayClassName="billable-filter-dropdown"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
height: '30px',
|
||||||
|
fontSize: '12px',
|
||||||
|
borderColor: colors.buttonBorder,
|
||||||
|
color: colors.buttonText,
|
||||||
|
fontWeight: activeFiltersCount > 0 ? 600 : 400,
|
||||||
|
transition: 'all 0.2s ease-in-out',
|
||||||
|
backgroundColor: colors.buttonBg,
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '4px 10px',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
e.currentTarget.style.backgroundColor = isDark ? '#262626' : '#f0f0f0';
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
e.currentTarget.style.backgroundColor = colors.buttonBg;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FilterOutlined
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: colors.buttonText,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{getButtonText()}</span>
|
||||||
|
<CaretDownFilled
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
marginLeft: '2px',
|
||||||
|
color: colors.buttonText,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Billable;
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import {
|
||||||
|
fetchReportingProjects,
|
||||||
|
setNoCategory,
|
||||||
|
setSelectOrDeselectAllCategories,
|
||||||
|
setSelectOrDeselectCategory,
|
||||||
|
} from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Divider,
|
||||||
|
Dropdown,
|
||||||
|
Input,
|
||||||
|
theme,
|
||||||
|
Space,
|
||||||
|
CaretDownFilled,
|
||||||
|
FilterOutlined,
|
||||||
|
CheckCircleFilled,
|
||||||
|
CheckboxChangeEvent
|
||||||
|
} from '@/shared/antd-imports';
|
||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const Categories: React.FC = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [selectAll, setSelectAll] = useState(true);
|
||||||
|
const { t } = useTranslation('time-report');
|
||||||
|
const [dropdownVisible, setDropdownVisible] = useState(false);
|
||||||
|
const { categories, loadingCategories, noCategory } = useAppSelector(
|
||||||
|
state => state.timeReportsOverviewReducer
|
||||||
|
);
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
// Calculate active filters count
|
||||||
|
const activeFiltersCount = useMemo(() => {
|
||||||
|
const selectedCategories = categories.filter(category => category.selected).length;
|
||||||
|
return selectedCategories + (noCategory ? 1 : 0);
|
||||||
|
}, [categories, noCategory]);
|
||||||
|
|
||||||
|
// Check if all options are selected
|
||||||
|
const isAllSelected =
|
||||||
|
categories.length > 0 && categories.every(category => category.selected) && noCategory;
|
||||||
|
const isNoneSelected =
|
||||||
|
categories.length > 0 && !categories.some(category => category.selected) && !noCategory;
|
||||||
|
|
||||||
|
const filteredItems = categories.filter(item =>
|
||||||
|
item.name?.toLowerCase().includes(searchText.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Theme-aware colors matching improved task filters
|
||||||
|
const isDark = token.colorBgContainer !== '#ffffff';
|
||||||
|
const colors = {
|
||||||
|
headerText: isDark ? '#8c8c8c' : '#595959',
|
||||||
|
borderColor: isDark ? '#404040' : '#f0f0f0',
|
||||||
|
linkActive: isDark ? '#d9d9d9' : '#1890ff',
|
||||||
|
linkDisabled: isDark ? '#8c8c8c' : '#d9d9d9',
|
||||||
|
successColor: isDark ? '#52c41a' : '#52c41a',
|
||||||
|
errorColor: isDark ? '#ff4d4f' : '#ff4d4f',
|
||||||
|
buttonBorder: isDark ? '#303030' : '#d9d9d9',
|
||||||
|
buttonText: activeFiltersCount > 0
|
||||||
|
? (isDark ? 'white' : '#262626')
|
||||||
|
: (isDark ? '#d9d9d9' : '#595959'),
|
||||||
|
buttonBg: activeFiltersCount > 0
|
||||||
|
? (isDark ? '#434343' : '#f5f5f5')
|
||||||
|
: (isDark ? '#141414' : 'white'),
|
||||||
|
dropdownBg: isDark ? '#1f1f1f' : 'white',
|
||||||
|
dropdownBorder: isDark ? '#303030' : '#d9d9d9',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle checkbox change for individual items
|
||||||
|
const handleCheckboxChange = async (key: string, checked: boolean) => {
|
||||||
|
await dispatch(setSelectOrDeselectCategory({ id: key, selected: checked }));
|
||||||
|
await dispatch(fetchReportingProjects());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle "Select All" checkbox change
|
||||||
|
const handleSelectAllChange = async (e: CheckboxChangeEvent) => {
|
||||||
|
const isChecked = e.target.checked;
|
||||||
|
setSelectAll(isChecked);
|
||||||
|
await dispatch(setNoCategory(isChecked));
|
||||||
|
await dispatch(setSelectOrDeselectAllCategories(isChecked));
|
||||||
|
await dispatch(fetchReportingProjects());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle select all button click
|
||||||
|
const handleSelectAllClick = async () => {
|
||||||
|
const newValue = !isAllSelected;
|
||||||
|
setSelectAll(newValue);
|
||||||
|
await dispatch(setNoCategory(newValue));
|
||||||
|
await dispatch(setSelectOrDeselectAllCategories(newValue));
|
||||||
|
await dispatch(fetchReportingProjects());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle clear all
|
||||||
|
const handleClearAll = async () => {
|
||||||
|
setSelectAll(false);
|
||||||
|
await dispatch(setNoCategory(false));
|
||||||
|
await dispatch(setSelectOrDeselectAllCategories(false));
|
||||||
|
await dispatch(fetchReportingProjects());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNoCategoryChange = async (checked: boolean) => {
|
||||||
|
await dispatch(setNoCategory(checked));
|
||||||
|
await dispatch(fetchReportingProjects());
|
||||||
|
};
|
||||||
|
|
||||||
|
const getButtonText = () => {
|
||||||
|
if (isNoneSelected) return t('categories');
|
||||||
|
if (isAllSelected) return `All ${t('categories')}`;
|
||||||
|
return `${t('categories')} (${activeFiltersCount})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Dropdown
|
||||||
|
menu={undefined}
|
||||||
|
placement="bottomLeft"
|
||||||
|
trigger={['click']}
|
||||||
|
dropdownRender={() => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: colors.dropdownBg,
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: isDark
|
||||||
|
? '0 6px 16px 0 rgba(0, 0, 0, 0.32), 0 3px 6px -4px rgba(0, 0, 0, 0.32), 0 9px 28px 8px rgba(0, 0, 0, 0.20)'
|
||||||
|
: '0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05)',
|
||||||
|
border: `1px solid ${colors.dropdownBorder}`,
|
||||||
|
padding: '4px 0',
|
||||||
|
maxHeight: '330px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '4px 4px 2px',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '12px',
|
||||||
|
color: colors.headerText,
|
||||||
|
borderBottom: `1px solid ${colors.borderColor}`,
|
||||||
|
marginBottom: '2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('searchByCategory')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div style={{ padding: '4px 8px', flexShrink: 0 }}>
|
||||||
|
<Input
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
placeholder={t('searchByCategory')}
|
||||||
|
value={searchText}
|
||||||
|
onChange={e => setSearchText(e.target.value)}
|
||||||
|
style={{ fontSize: '14px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{categories.length > 0 && (
|
||||||
|
<div style={{ padding: '2px 8px', marginBottom: '2px' }}>
|
||||||
|
<Space size="small">
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={handleSelectAllClick}
|
||||||
|
disabled={isAllSelected}
|
||||||
|
style={{
|
||||||
|
padding: '0 2px',
|
||||||
|
height: 'auto',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: isAllSelected ? colors.linkDisabled : colors.linkActive,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('selectAll')}
|
||||||
|
</Button>
|
||||||
|
<Divider type="vertical" style={{ margin: '0 2px' }} />
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={handleClearAll}
|
||||||
|
disabled={isNoneSelected}
|
||||||
|
style={{
|
||||||
|
padding: '0 2px',
|
||||||
|
height: 'auto',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: isNoneSelected ? colors.linkDisabled : colors.errorColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('clearAll')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No Category Option */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
checked={noCategory}
|
||||||
|
onChange={e => handleNoCategoryChange(e.target.checked)}
|
||||||
|
style={{ fontSize: '14px' }}
|
||||||
|
>
|
||||||
|
<span style={{ marginLeft: '2px', fontSize: '14px' }}>{t('noCategory')}</span>
|
||||||
|
</Checkbox>
|
||||||
|
{noCategory && (
|
||||||
|
<CheckCircleFilled style={{ color: colors.successColor, fontSize: '10px' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '2px 0', flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
overflowY: 'auto',
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredItems.length > 0 ? (
|
||||||
|
filteredItems.map(item => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
checked={item.selected}
|
||||||
|
onChange={e => handleCheckboxChange(item.id || '', e.target.checked)}
|
||||||
|
style={{ fontSize: '14px' }}
|
||||||
|
>
|
||||||
|
<span style={{ marginLeft: '2px', fontSize: '14px' }}>{item.name}</span>
|
||||||
|
</Checkbox>
|
||||||
|
{item.selected && (
|
||||||
|
<CheckCircleFilled style={{ color: colors.successColor, fontSize: '10px' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div style={{ padding: '4px 8px', fontSize: '14px', color: colors.headerText }}>
|
||||||
|
{t('noCategories')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
onOpenChange={visible => {
|
||||||
|
setDropdownVisible(visible);
|
||||||
|
if (!visible) {
|
||||||
|
setSearchText('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
loading={loadingCategories}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
height: '30px',
|
||||||
|
fontSize: '12px',
|
||||||
|
borderColor: colors.buttonBorder,
|
||||||
|
color: colors.buttonText,
|
||||||
|
fontWeight: activeFiltersCount > 0 ? 600 : 400,
|
||||||
|
transition: 'all 0.2s ease-in-out',
|
||||||
|
backgroundColor: colors.buttonBg,
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '4px 10px',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
e.currentTarget.style.backgroundColor = isDark ? '#262626' : '#f0f0f0';
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
e.currentTarget.style.backgroundColor = colors.buttonBg;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FilterOutlined
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: colors.buttonText,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{getButtonText()}</span>
|
||||||
|
<CaretDownFilled
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
marginLeft: '2px',
|
||||||
|
color: colors.buttonText,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Categories;
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Divider,
|
||||||
|
Dropdown,
|
||||||
|
Input,
|
||||||
|
Avatar,
|
||||||
|
theme,
|
||||||
|
Space,
|
||||||
|
CaretDownFilled,
|
||||||
|
FilterOutlined,
|
||||||
|
CheckCircleFilled,
|
||||||
|
CheckboxChangeEvent,
|
||||||
|
} from '@/shared/antd-imports';
|
||||||
|
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import {
|
||||||
|
setSelectOrDeselectAllMembers,
|
||||||
|
setSelectOrDeselectMember,
|
||||||
|
} from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||||
|
|
||||||
|
const Members: React.FC = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { t } = useTranslation('time-report');
|
||||||
|
const { members, loadingMembers } = useAppSelector(state => state.timeReportsOverviewReducer);
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [selectAll, setSelectAll] = useState(true);
|
||||||
|
|
||||||
|
// Calculate active filters count
|
||||||
|
const activeFiltersCount = useMemo(() => {
|
||||||
|
return members.filter(member => member.selected).length;
|
||||||
|
}, [members]);
|
||||||
|
|
||||||
|
// Check if all options are selected
|
||||||
|
const isAllSelected = members.length > 0 && members.every(member => member.selected);
|
||||||
|
const isNoneSelected = members.length > 0 && !members.some(member => member.selected);
|
||||||
|
|
||||||
|
// Filter members based on search text
|
||||||
|
const filteredMembers = members.filter(member =>
|
||||||
|
member.name?.toLowerCase().includes(searchText.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Theme-aware colors matching improved task filters
|
||||||
|
const isDark = token.colorBgContainer !== '#ffffff';
|
||||||
|
const colors = {
|
||||||
|
headerText: isDark ? '#8c8c8c' : '#595959',
|
||||||
|
borderColor: isDark ? '#404040' : '#f0f0f0',
|
||||||
|
linkActive: isDark ? '#d9d9d9' : '#1890ff',
|
||||||
|
linkDisabled: isDark ? '#8c8c8c' : '#d9d9d9',
|
||||||
|
successColor: isDark ? '#52c41a' : '#52c41a',
|
||||||
|
errorColor: isDark ? '#ff4d4f' : '#ff4d4f',
|
||||||
|
buttonBorder: isDark ? '#303030' : '#d9d9d9',
|
||||||
|
buttonText: activeFiltersCount > 0
|
||||||
|
? (isDark ? 'white' : '#262626')
|
||||||
|
: (isDark ? '#d9d9d9' : '#595959'),
|
||||||
|
buttonBg: activeFiltersCount > 0
|
||||||
|
? (isDark ? '#434343' : '#f5f5f5')
|
||||||
|
: (isDark ? '#141414' : 'white'),
|
||||||
|
dropdownBg: isDark ? '#1f1f1f' : 'white',
|
||||||
|
dropdownBorder: isDark ? '#303030' : '#d9d9d9',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle checkbox change for individual members
|
||||||
|
const handleCheckboxChange = (id: string, checked: boolean) => {
|
||||||
|
dispatch(setSelectOrDeselectMember({ id, selected: checked }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle "Select All" checkbox change
|
||||||
|
const handleSelectAllChange = (e: CheckboxChangeEvent) => {
|
||||||
|
const isChecked = e.target.checked;
|
||||||
|
setSelectAll(isChecked);
|
||||||
|
dispatch(setSelectOrDeselectAllMembers(isChecked));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle select all button click
|
||||||
|
const handleSelectAllClick = () => {
|
||||||
|
const newValue = !isAllSelected;
|
||||||
|
setSelectAll(newValue);
|
||||||
|
dispatch(setSelectOrDeselectAllMembers(newValue));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle clear all
|
||||||
|
const handleClearAll = () => {
|
||||||
|
setSelectAll(false);
|
||||||
|
dispatch(setSelectOrDeselectAllMembers(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getButtonText = () => {
|
||||||
|
if (isNoneSelected) return t('members');
|
||||||
|
if (isAllSelected) return `All ${t('members')}`;
|
||||||
|
return `${t('members')} (${activeFiltersCount})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
menu={undefined}
|
||||||
|
placement="bottomLeft"
|
||||||
|
trigger={['click']}
|
||||||
|
dropdownRender={() => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: colors.dropdownBg,
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: isDark
|
||||||
|
? '0 6px 16px 0 rgba(0, 0, 0, 0.32), 0 3px 6px -4px rgba(0, 0, 0, 0.32), 0 9px 28px 8px rgba(0, 0, 0, 0.20)'
|
||||||
|
: '0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05)',
|
||||||
|
border: `1px solid ${colors.dropdownBorder}`,
|
||||||
|
padding: '4px 0',
|
||||||
|
maxHeight: '330px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '4px 4px 2px',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '12px',
|
||||||
|
color: colors.headerText,
|
||||||
|
borderBottom: `1px solid ${colors.borderColor}`,
|
||||||
|
marginBottom: '2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('searchByMember')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div style={{ padding: '4px 8px', flexShrink: 0 }}>
|
||||||
|
<Input
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
placeholder={t('searchByMember')}
|
||||||
|
value={searchText}
|
||||||
|
onChange={e => setSearchText(e.target.value)}
|
||||||
|
style={{ fontSize: '14px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div style={{ padding: '2px 8px', marginBottom: '2px' }}>
|
||||||
|
<Space size="small">
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={handleSelectAllClick}
|
||||||
|
disabled={isAllSelected}
|
||||||
|
style={{
|
||||||
|
padding: '0 2px',
|
||||||
|
height: 'auto',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: isAllSelected ? colors.linkDisabled : colors.linkActive,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('selectAll')}
|
||||||
|
</Button>
|
||||||
|
<Divider type="vertical" style={{ margin: '0 2px' }} />
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={handleClearAll}
|
||||||
|
disabled={isNoneSelected}
|
||||||
|
style={{
|
||||||
|
padding: '0 2px',
|
||||||
|
height: 'auto',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: isNoneSelected ? colors.linkDisabled : colors.errorColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('clearAll')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '2px 0', flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
overflowY: 'auto',
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredMembers.map(member => (
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: '4px',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar src={member.avatar_url} alt={member.name} size="small" />
|
||||||
|
<Checkbox
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
checked={member.selected}
|
||||||
|
onChange={e => handleCheckboxChange(member.id, e.target.checked)}
|
||||||
|
style={{ fontSize: '14px' }}
|
||||||
|
>
|
||||||
|
<span style={{ marginLeft: '2px', fontSize: '14px' }}>{member.name}</span>
|
||||||
|
</Checkbox>
|
||||||
|
{member.selected && (
|
||||||
|
<CheckCircleFilled
|
||||||
|
style={{ color: colors.successColor, fontSize: '10px', marginLeft: 'auto' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
loading={loadingMembers}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
height: '30px',
|
||||||
|
fontSize: '12px',
|
||||||
|
borderColor: colors.buttonBorder,
|
||||||
|
color: colors.buttonText,
|
||||||
|
fontWeight: activeFiltersCount > 0 ? 600 : 400,
|
||||||
|
transition: 'all 0.2s ease-in-out',
|
||||||
|
backgroundColor: colors.buttonBg,
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '4px 10px',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
e.currentTarget.style.backgroundColor = isDark ? '#262626' : '#f0f0f0';
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
e.currentTarget.style.backgroundColor = colors.buttonBg;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FilterOutlined
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: colors.buttonText,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{getButtonText()}</span>
|
||||||
|
<CaretDownFilled
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
marginLeft: '2px',
|
||||||
|
color: colors.buttonText,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Members;
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Divider,
|
||||||
|
Dropdown,
|
||||||
|
Input,
|
||||||
|
theme,
|
||||||
|
Space,
|
||||||
|
CaretDownFilled,
|
||||||
|
FilterOutlined,
|
||||||
|
CheckCircleFilled,
|
||||||
|
CheckboxChangeEvent,
|
||||||
|
} from '@/shared/antd-imports';
|
||||||
|
import {
|
||||||
|
setSelectOrDeselectAllProjects,
|
||||||
|
setSelectOrDeselectProject,
|
||||||
|
} from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
|
||||||
|
const Projects: React.FC = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const [checkedList, setCheckedList] = useState<string[]>([]);
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [selectAll, setSelectAll] = useState(true);
|
||||||
|
const { t } = useTranslation('time-report');
|
||||||
|
const [dropdownVisible, setDropdownVisible] = useState(false);
|
||||||
|
const { projects, loadingProjects } = useAppSelector(state => state.timeReportsOverviewReducer);
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
// Calculate active filters count
|
||||||
|
const activeFiltersCount = useMemo(() => {
|
||||||
|
return projects.filter(project => project.selected).length;
|
||||||
|
}, [projects]);
|
||||||
|
|
||||||
|
// Check if all options are selected
|
||||||
|
const isAllSelected = projects.length > 0 && projects.every(project => project.selected);
|
||||||
|
const isNoneSelected = projects.length > 0 && !projects.some(project => project.selected);
|
||||||
|
|
||||||
|
// Filter items based on search text
|
||||||
|
const filteredItems = projects.filter(item =>
|
||||||
|
item.name?.toLowerCase().includes(searchText.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Theme-aware colors matching improved task filters
|
||||||
|
const isDark = token.colorBgContainer !== '#ffffff';
|
||||||
|
const colors = {
|
||||||
|
headerText: isDark ? '#8c8c8c' : '#595959',
|
||||||
|
borderColor: isDark ? '#404040' : '#f0f0f0',
|
||||||
|
linkActive: isDark ? '#d9d9d9' : '#1890ff',
|
||||||
|
linkDisabled: isDark ? '#8c8c8c' : '#d9d9d9',
|
||||||
|
successColor: isDark ? '#52c41a' : '#52c41a',
|
||||||
|
errorColor: isDark ? '#ff4d4f' : '#ff4d4f',
|
||||||
|
buttonBorder: isDark ? '#303030' : '#d9d9d9',
|
||||||
|
buttonText: activeFiltersCount > 0
|
||||||
|
? (isDark ? 'white' : '#262626')
|
||||||
|
: (isDark ? '#d9d9d9' : '#595959'),
|
||||||
|
buttonBg: activeFiltersCount > 0
|
||||||
|
? (isDark ? '#434343' : '#f5f5f5')
|
||||||
|
: (isDark ? '#141414' : 'white'),
|
||||||
|
dropdownBg: isDark ? '#1f1f1f' : 'white',
|
||||||
|
dropdownBorder: isDark ? '#303030' : '#d9d9d9',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle checkbox change for individual items
|
||||||
|
const handleCheckboxChange = (key: string, checked: boolean) => {
|
||||||
|
dispatch(setSelectOrDeselectProject({ id: key, selected: checked }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle "Select All" checkbox change
|
||||||
|
const handleSelectAllChange = (e: CheckboxChangeEvent) => {
|
||||||
|
const isChecked = e.target.checked;
|
||||||
|
setSelectAll(isChecked);
|
||||||
|
dispatch(setSelectOrDeselectAllProjects(isChecked));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle select all button click
|
||||||
|
const handleSelectAllClick = () => {
|
||||||
|
const newValue = !isAllSelected;
|
||||||
|
setSelectAll(newValue);
|
||||||
|
dispatch(setSelectOrDeselectAllProjects(newValue));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle clear all
|
||||||
|
const handleClearAll = () => {
|
||||||
|
setSelectAll(false);
|
||||||
|
dispatch(setSelectOrDeselectAllProjects(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getButtonText = () => {
|
||||||
|
if (isNoneSelected) return t('projects');
|
||||||
|
if (isAllSelected) return `All ${t('projects')}`;
|
||||||
|
return `${t('projects')} (${activeFiltersCount})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Dropdown
|
||||||
|
menu={undefined}
|
||||||
|
placement="bottomLeft"
|
||||||
|
trigger={['click']}
|
||||||
|
dropdownRender={() => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: colors.dropdownBg,
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: isDark
|
||||||
|
? '0 6px 16px 0 rgba(0, 0, 0, 0.32), 0 3px 6px -4px rgba(0, 0, 0, 0.32), 0 9px 28px 8px rgba(0, 0, 0, 0.20)'
|
||||||
|
: '0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05)',
|
||||||
|
border: `1px solid ${colors.dropdownBorder}`,
|
||||||
|
padding: '4px 0',
|
||||||
|
maxHeight: '330px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '4px 4px 2px',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '12px',
|
||||||
|
color: colors.headerText,
|
||||||
|
borderBottom: `1px solid ${colors.borderColor}`,
|
||||||
|
marginBottom: '2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('searchByProject')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div style={{ padding: '4px 8px', flexShrink: 0 }}>
|
||||||
|
<Input
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
placeholder={t('searchByProject')}
|
||||||
|
value={searchText}
|
||||||
|
onChange={e => setSearchText(e.target.value)}
|
||||||
|
style={{ fontSize: '14px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div style={{ padding: '2px 8px', marginBottom: '2px' }}>
|
||||||
|
<Space size="small">
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={handleSelectAllClick}
|
||||||
|
disabled={isAllSelected}
|
||||||
|
style={{
|
||||||
|
padding: '0 2px',
|
||||||
|
height: 'auto',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: isAllSelected ? colors.linkDisabled : colors.linkActive,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('selectAll')}
|
||||||
|
</Button>
|
||||||
|
<Divider type="vertical" style={{ margin: '0 2px' }} />
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={handleClearAll}
|
||||||
|
disabled={isNoneSelected}
|
||||||
|
style={{
|
||||||
|
padding: '0 2px',
|
||||||
|
height: 'auto',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: isNoneSelected ? colors.linkDisabled : colors.errorColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('clearAll')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '2px 0', flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
overflowY: 'auto',
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredItems.map(item => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
checked={item.selected}
|
||||||
|
onChange={e => handleCheckboxChange(item.id || '', e.target.checked)}
|
||||||
|
style={{ fontSize: '14px' }}
|
||||||
|
>
|
||||||
|
<span style={{ marginLeft: '2px', fontSize: '14px' }}>{item.name}</span>
|
||||||
|
</Checkbox>
|
||||||
|
{item.selected && (
|
||||||
|
<CheckCircleFilled style={{ color: colors.successColor, fontSize: '10px' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
onOpenChange={visible => {
|
||||||
|
setDropdownVisible(visible);
|
||||||
|
if (!visible) {
|
||||||
|
setSearchText('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
loading={loadingProjects}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
height: '30px',
|
||||||
|
fontSize: '12px',
|
||||||
|
borderColor: colors.buttonBorder,
|
||||||
|
color: colors.buttonText,
|
||||||
|
fontWeight: activeFiltersCount > 0 ? 600 : 400,
|
||||||
|
transition: 'all 0.2s ease-in-out',
|
||||||
|
backgroundColor: colors.buttonBg,
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '4px 10px',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
e.currentTarget.style.backgroundColor = isDark ? '#262626' : '#f0f0f0';
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
e.currentTarget.style.backgroundColor = colors.buttonBg;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FilterOutlined
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: colors.buttonText,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{getButtonText()}</span>
|
||||||
|
<CaretDownFilled
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
marginLeft: '2px',
|
||||||
|
color: colors.buttonText,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Projects;
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Divider,
|
||||||
|
Dropdown,
|
||||||
|
Input,
|
||||||
|
theme,
|
||||||
|
Space,
|
||||||
|
CaretDownFilled,
|
||||||
|
FilterOutlined,
|
||||||
|
CheckCircleFilled,
|
||||||
|
CheckboxChangeEvent,
|
||||||
|
} from '@/shared/antd-imports';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import {
|
||||||
|
fetchReportingCategories,
|
||||||
|
fetchReportingProjects,
|
||||||
|
setSelectOrDeselectAllTeams,
|
||||||
|
setSelectOrDeselectTeam,
|
||||||
|
} from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||||
|
|
||||||
|
const Team: React.FC = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [selectAll, setSelectAll] = useState(true);
|
||||||
|
const { t } = useTranslation('time-report');
|
||||||
|
const [dropdownVisible, setDropdownVisible] = useState(false);
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
const { teams, loadingTeams } = useAppSelector(state => state.timeReportsOverviewReducer);
|
||||||
|
|
||||||
|
// Calculate active filters count
|
||||||
|
const activeFiltersCount = useMemo(() => {
|
||||||
|
return teams.filter(team => team.selected).length;
|
||||||
|
}, [teams]);
|
||||||
|
|
||||||
|
// Check if all options are selected
|
||||||
|
const isAllSelected = teams.length > 0 && teams.every(team => team.selected);
|
||||||
|
const isNoneSelected = teams.length > 0 && !teams.some(team => team.selected);
|
||||||
|
|
||||||
|
const filteredItems = teams.filter(item =>
|
||||||
|
item.name?.toLowerCase().includes(searchText.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Theme-aware colors matching improved task filters
|
||||||
|
const isDark = token.colorBgContainer !== '#ffffff';
|
||||||
|
const colors = {
|
||||||
|
headerText: isDark ? '#8c8c8c' : '#595959',
|
||||||
|
borderColor: isDark ? '#404040' : '#f0f0f0',
|
||||||
|
linkActive: isDark ? '#d9d9d9' : '#1890ff',
|
||||||
|
linkDisabled: isDark ? '#8c8c8c' : '#d9d9d9',
|
||||||
|
successColor: isDark ? '#52c41a' : '#52c41a',
|
||||||
|
errorColor: isDark ? '#ff4d4f' : '#ff4d4f',
|
||||||
|
buttonBorder: isDark ? '#303030' : '#d9d9d9',
|
||||||
|
buttonText: activeFiltersCount > 0
|
||||||
|
? (isDark ? 'white' : '#262626')
|
||||||
|
: (isDark ? '#d9d9d9' : '#595959'),
|
||||||
|
buttonBg: activeFiltersCount > 0
|
||||||
|
? (isDark ? '#434343' : '#f5f5f5')
|
||||||
|
: (isDark ? '#141414' : 'white'),
|
||||||
|
dropdownBg: isDark ? '#1f1f1f' : 'white',
|
||||||
|
dropdownBorder: isDark ? '#303030' : '#d9d9d9',
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckboxChange = async (key: string, checked: boolean) => {
|
||||||
|
dispatch(setSelectOrDeselectTeam({ id: key, selected: checked }));
|
||||||
|
await dispatch(fetchReportingCategories());
|
||||||
|
await dispatch(fetchReportingProjects());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAllChange = async (e: CheckboxChangeEvent) => {
|
||||||
|
const isChecked = e.target.checked;
|
||||||
|
setSelectAll(isChecked);
|
||||||
|
dispatch(setSelectOrDeselectAllTeams(isChecked));
|
||||||
|
await dispatch(fetchReportingCategories());
|
||||||
|
await dispatch(fetchReportingProjects());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle clear all
|
||||||
|
const handleClearAll = async () => {
|
||||||
|
setSelectAll(false);
|
||||||
|
dispatch(setSelectOrDeselectAllTeams(false));
|
||||||
|
await dispatch(fetchReportingCategories());
|
||||||
|
await dispatch(fetchReportingProjects());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle select all button click
|
||||||
|
const handleSelectAllClick = async () => {
|
||||||
|
const newValue = !isAllSelected;
|
||||||
|
setSelectAll(newValue);
|
||||||
|
dispatch(setSelectOrDeselectAllTeams(newValue));
|
||||||
|
await dispatch(fetchReportingCategories());
|
||||||
|
await dispatch(fetchReportingProjects());
|
||||||
|
};
|
||||||
|
|
||||||
|
const getButtonText = () => {
|
||||||
|
if (isNoneSelected) return t('teams');
|
||||||
|
if (isAllSelected) return `All ${t('teams')}`;
|
||||||
|
return `${t('teams')} (${activeFiltersCount})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Dropdown
|
||||||
|
menu={undefined}
|
||||||
|
placement="bottomLeft"
|
||||||
|
trigger={['click']}
|
||||||
|
dropdownRender={() => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: colors.dropdownBg,
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: isDark
|
||||||
|
? '0 6px 16px 0 rgba(0, 0, 0, 0.32), 0 3px 6px -4px rgba(0, 0, 0, 0.32), 0 9px 28px 8px rgba(0, 0, 0, 0.20)'
|
||||||
|
: '0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05)',
|
||||||
|
border: `1px solid ${colors.dropdownBorder}`,
|
||||||
|
padding: '4px 0',
|
||||||
|
maxHeight: '330px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '4px 4px 2px',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '12px',
|
||||||
|
color: colors.headerText,
|
||||||
|
borderBottom: `1px solid ${colors.borderColor}`,
|
||||||
|
marginBottom: '2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('searchByName')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div style={{ padding: '4px 8px', flexShrink: 0 }}>
|
||||||
|
<Input
|
||||||
|
placeholder={t('searchByName')}
|
||||||
|
value={searchText}
|
||||||
|
onChange={e => setSearchText(e.target.value)}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{ fontSize: '14px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div style={{ padding: '2px 8px', marginBottom: '2px' }}>
|
||||||
|
<Space size="small">
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={handleSelectAllClick}
|
||||||
|
disabled={isAllSelected}
|
||||||
|
style={{
|
||||||
|
padding: '0 2px',
|
||||||
|
height: 'auto',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: isAllSelected ? colors.linkDisabled : colors.linkActive,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('selectAll')}
|
||||||
|
</Button>
|
||||||
|
<Divider type="vertical" style={{ margin: '0 2px' }} />
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={handleClearAll}
|
||||||
|
disabled={isNoneSelected}
|
||||||
|
style={{
|
||||||
|
padding: '0 2px',
|
||||||
|
height: 'auto',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: isNoneSelected ? colors.linkDisabled : colors.errorColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('clearAll')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '2px 0', flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
overflowY: 'auto',
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredItems.map(item => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
checked={item.selected}
|
||||||
|
onChange={e => handleCheckboxChange(item.id || '', e.target.checked)}
|
||||||
|
style={{ fontSize: '14px' }}
|
||||||
|
>
|
||||||
|
<span style={{ marginLeft: '2px', fontSize: '14px' }}>{item.name}</span>
|
||||||
|
</Checkbox>
|
||||||
|
{item.selected && (
|
||||||
|
<CheckCircleFilled style={{ color: colors.successColor, fontSize: '10px' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
onOpenChange={visible => {
|
||||||
|
setDropdownVisible(visible);
|
||||||
|
if (!visible) {
|
||||||
|
setSearchText('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
loading={loadingTeams}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
height: '30px',
|
||||||
|
fontSize: '12px',
|
||||||
|
borderColor: colors.buttonBorder,
|
||||||
|
color: colors.buttonText,
|
||||||
|
fontWeight: activeFiltersCount > 0 ? 600 : 400,
|
||||||
|
transition: 'all 0.2s ease-in-out',
|
||||||
|
backgroundColor: colors.buttonBg,
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '4px 10px',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
e.currentTarget.style.backgroundColor = isDark ? '#262626' : '#f0f0f0';
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
e.currentTarget.style.backgroundColor = colors.buttonBg;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FilterOutlined
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: colors.buttonText,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{getButtonText()}</span>
|
||||||
|
<CaretDownFilled
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
marginLeft: '2px',
|
||||||
|
color: colors.buttonText,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Team;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import Team from './Team';
|
||||||
|
import Categories from './Categories';
|
||||||
|
import Projects from './Projects';
|
||||||
|
import Billable from './Billable';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import {
|
||||||
|
fetchReportingTeams,
|
||||||
|
fetchReportingProjects,
|
||||||
|
fetchReportingCategories,
|
||||||
|
fetchReportingMembers,
|
||||||
|
fetchReportingUtilization,
|
||||||
|
} from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||||
|
import Members from './Members';
|
||||||
|
import Utilization from './Utilization';
|
||||||
|
|
||||||
|
const TimeReportPageHeader: React.FC = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// Check if current route is members time sheet
|
||||||
|
const isMembersTimeSheet = location.pathname.includes('time-sheet-members');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
await dispatch(fetchReportingTeams());
|
||||||
|
await dispatch(fetchReportingCategories());
|
||||||
|
await dispatch(fetchReportingProjects());
|
||||||
|
|
||||||
|
// Only fetch members and utilization data for members time sheet
|
||||||
|
if (isMembersTimeSheet) {
|
||||||
|
await dispatch(fetchReportingMembers());
|
||||||
|
await dispatch(fetchReportingUtilization());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [dispatch, isMembersTimeSheet]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<Team />
|
||||||
|
<Categories />
|
||||||
|
<Projects />
|
||||||
|
<Billable />
|
||||||
|
{isMembersTimeSheet && <Members/>}
|
||||||
|
{isMembersTimeSheet && <Utilization />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TimeReportPageHeader;
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import {
|
||||||
|
setSelectOrDeselectAllUtilization,
|
||||||
|
setSelectOrDeselectUtilization,
|
||||||
|
} from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Divider,
|
||||||
|
Dropdown,
|
||||||
|
Input,
|
||||||
|
Avatar,
|
||||||
|
theme,
|
||||||
|
Space,
|
||||||
|
CaretDownFilled,
|
||||||
|
FilterOutlined,
|
||||||
|
CheckCircleFilled,
|
||||||
|
CheckboxChangeEvent,
|
||||||
|
} from '@/shared/antd-imports';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const Utilization: React.FC = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { t } = useTranslation('time-report');
|
||||||
|
const { utilization, loadingUtilization } = useAppSelector(
|
||||||
|
state => state.timeReportsOverviewReducer
|
||||||
|
);
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [selectAll, setSelectAll] = useState(true);
|
||||||
|
|
||||||
|
// Calculate active filters count
|
||||||
|
const activeFiltersCount = useMemo(() => {
|
||||||
|
return utilization.filter(item => item.selected).length;
|
||||||
|
}, [utilization]);
|
||||||
|
|
||||||
|
// Check if all options are selected
|
||||||
|
const isAllSelected = utilization.length > 0 && utilization.every(item => item.selected);
|
||||||
|
const isNoneSelected = utilization.length > 0 && !utilization.some(item => item.selected);
|
||||||
|
|
||||||
|
// Filter members based on search text
|
||||||
|
const filteredItems = utilization.filter(item =>
|
||||||
|
item.name?.toLowerCase().includes(searchText.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Theme-aware colors matching improved task filters
|
||||||
|
const isDark = token.colorBgContainer !== '#ffffff';
|
||||||
|
const colors = {
|
||||||
|
headerText: isDark ? '#8c8c8c' : '#595959',
|
||||||
|
borderColor: isDark ? '#404040' : '#f0f0f0',
|
||||||
|
linkActive: isDark ? '#d9d9d9' : '#1890ff',
|
||||||
|
linkDisabled: isDark ? '#8c8c8c' : '#d9d9d9',
|
||||||
|
successColor: isDark ? '#52c41a' : '#52c41a',
|
||||||
|
errorColor: isDark ? '#ff4d4f' : '#ff4d4f',
|
||||||
|
buttonBorder: isDark ? '#303030' : '#d9d9d9',
|
||||||
|
buttonText: activeFiltersCount > 0
|
||||||
|
? (isDark ? 'white' : '#262626')
|
||||||
|
: (isDark ? '#d9d9d9' : '#595959'),
|
||||||
|
buttonBg: activeFiltersCount > 0
|
||||||
|
? (isDark ? '#434343' : '#f5f5f5')
|
||||||
|
: (isDark ? '#141414' : 'white'),
|
||||||
|
dropdownBg: isDark ? '#1f1f1f' : 'white',
|
||||||
|
dropdownBorder: isDark ? '#303030' : '#d9d9d9',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle checkbox change for individual members
|
||||||
|
const handleCheckboxChange = (id: string, selected: boolean) => {
|
||||||
|
dispatch(setSelectOrDeselectUtilization({ id, selected }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAll = (e: CheckboxChangeEvent) => {
|
||||||
|
const isChecked = e.target.checked;
|
||||||
|
setSelectAll(isChecked);
|
||||||
|
dispatch(setSelectOrDeselectAllUtilization(isChecked));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle select all button click
|
||||||
|
const handleSelectAllClick = () => {
|
||||||
|
const newValue = !isAllSelected;
|
||||||
|
setSelectAll(newValue);
|
||||||
|
dispatch(setSelectOrDeselectAllUtilization(newValue));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle clear all
|
||||||
|
const handleClearAll = () => {
|
||||||
|
setSelectAll(false);
|
||||||
|
dispatch(setSelectOrDeselectAllUtilization(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getButtonText = () => {
|
||||||
|
if (isNoneSelected) return t('utilization');
|
||||||
|
if (isAllSelected) return `All ${t('utilization')}`;
|
||||||
|
return `${t('utilization')} (${activeFiltersCount})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
menu={undefined}
|
||||||
|
placement="bottomLeft"
|
||||||
|
trigger={['click']}
|
||||||
|
dropdownRender={() => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: colors.dropdownBg,
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: isDark
|
||||||
|
? '0 6px 16px 0 rgba(0, 0, 0, 0.32), 0 3px 6px -4px rgba(0, 0, 0, 0.32), 0 9px 28px 8px rgba(0, 0, 0, 0.20)'
|
||||||
|
: '0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05)',
|
||||||
|
border: `1px solid ${colors.dropdownBorder}`,
|
||||||
|
padding: '4px 0',
|
||||||
|
maxHeight: '330px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '4px 4px 2px',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '12px',
|
||||||
|
color: colors.headerText,
|
||||||
|
borderBottom: `1px solid ${colors.borderColor}`,
|
||||||
|
marginBottom: '2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('utilization')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div style={{ padding: '2px 8px', marginBottom: '2px' }}>
|
||||||
|
<Space size="small">
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={handleSelectAllClick}
|
||||||
|
disabled={isAllSelected}
|
||||||
|
style={{
|
||||||
|
padding: '0 2px',
|
||||||
|
height: 'auto',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: isAllSelected ? colors.linkDisabled : colors.linkActive,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('selectAll')}
|
||||||
|
</Button>
|
||||||
|
<Divider type="vertical" style={{ margin: '0 2px' }} />
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={handleClearAll}
|
||||||
|
disabled={isNoneSelected}
|
||||||
|
style={{
|
||||||
|
padding: '0 2px',
|
||||||
|
height: 'auto',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: isNoneSelected ? colors.linkDisabled : colors.errorColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('clearAll')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '2px 0', flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
overflowY: 'auto',
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredItems.map((ut, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: '4px',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
checked={ut.selected}
|
||||||
|
onChange={e => handleCheckboxChange(ut.id, e.target.checked)}
|
||||||
|
style={{ fontSize: '14px' }}
|
||||||
|
>
|
||||||
|
<span style={{ marginLeft: '2px', fontSize: '14px' }}>{ut.name}</span>
|
||||||
|
</Checkbox>
|
||||||
|
{ut.selected && (
|
||||||
|
<CheckCircleFilled
|
||||||
|
style={{ color: colors.successColor, fontSize: '10px', marginLeft: 'auto' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
loading={loadingUtilization}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
height: '30px',
|
||||||
|
fontSize: '12px',
|
||||||
|
borderColor: colors.buttonBorder,
|
||||||
|
color: colors.buttonText,
|
||||||
|
fontWeight: activeFiltersCount > 0 ? 600 : 400,
|
||||||
|
transition: 'all 0.2s ease-in-out',
|
||||||
|
backgroundColor: colors.buttonBg,
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '4px 10px',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
e.currentTarget.style.backgroundColor = isDark ? '#262626' : '#f0f0f0';
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
e.currentTarget.style.backgroundColor = colors.buttonBg;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FilterOutlined
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: colors.buttonText,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{getButtonText()}</span>
|
||||||
|
<CaretDownFilled
|
||||||
|
style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
marginLeft: '2px',
|
||||||
|
color: colors.buttonText,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Utilization;
|
||||||
@@ -2,11 +2,11 @@ import React from 'react';
|
|||||||
import { Button, Checkbox, Dropdown, Space, Typography } from '@/shared/antd-imports';
|
import { Button, Checkbox, Dropdown, Space, Typography } from '@/shared/antd-imports';
|
||||||
import { DownOutlined } from '@/shared/antd-imports';
|
import { DownOutlined } from '@/shared/antd-imports';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import CustomPageHeader from '../../page-header/custom-page-header';
|
import TimeWiseFilter from '@/components/reporting/time-wise-filter';
|
||||||
import TimeWiseFilter from '../../../../components/reporting/time-wise-filter';
|
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { setArchived } from '@/features/reporting/time-reports/time-reports-overview.slice';
|
import { setArchived } from '@/features/reporting/time-reports/time-reports-overview.slice';
|
||||||
|
import CustomPageHeader from '@/pages/reporting/page-header/custom-page-header';
|
||||||
|
|
||||||
interface headerState {
|
interface headerState {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -17,7 +17,7 @@ const LanguageSelector = () => {
|
|||||||
{ key: 'pt', label: 'Português' },
|
{ key: 'pt', label: 'Português' },
|
||||||
{ key: 'alb', label: 'Shqip' },
|
{ key: 'alb', label: 'Shqip' },
|
||||||
{ key: 'de', label: 'Deutsch' },
|
{ key: 'de', label: 'Deutsch' },
|
||||||
{ key: 'zh_cn', label: '简体中文' },
|
{ key: 'zh', label: '简体中文' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const languageLabels = {
|
const languageLabels = {
|
||||||
@@ -26,7 +26,7 @@ const LanguageSelector = () => {
|
|||||||
pt: 'Pt',
|
pt: 'Pt',
|
||||||
alb: 'Sq',
|
alb: 'Sq',
|
||||||
de: 'de',
|
de: 'de',
|
||||||
zh_cn: 'zh_cn',
|
zh: 'zh',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export enum Language {
|
|||||||
PT = 'pt',
|
PT = 'pt',
|
||||||
ALB = 'alb',
|
ALB = 'alb',
|
||||||
DE = 'de',
|
DE = 'de',
|
||||||
ZH_CN = 'zh_cn',
|
ZH = 'zh',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ILanguageType = `${Language}`;
|
export type ILanguageType = `${Language}`;
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ interface ITimeReportsOverviewState {
|
|||||||
billable: boolean;
|
billable: boolean;
|
||||||
nonBillable: boolean;
|
nonBillable: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
members: any[];
|
||||||
|
loadingMembers: boolean;
|
||||||
|
|
||||||
|
utilization: any[];
|
||||||
|
loadingUtilization: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: ITimeReportsOverviewState = {
|
const initialState: ITimeReportsOverviewState = {
|
||||||
@@ -42,6 +48,15 @@ const initialState: ITimeReportsOverviewState = {
|
|||||||
billable: true,
|
billable: true,
|
||||||
nonBillable: true,
|
nonBillable: true,
|
||||||
},
|
},
|
||||||
|
members: [],
|
||||||
|
loadingMembers: false,
|
||||||
|
|
||||||
|
utilization: [],
|
||||||
|
loadingUtilization: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedMembers = (state: ITimeReportsOverviewState) => {
|
||||||
|
return state.members.filter(member => member.selected).map(member => member.id) as string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedTeams = (state: ITimeReportsOverviewState) => {
|
const selectedTeams = (state: ITimeReportsOverviewState) => {
|
||||||
@@ -54,6 +69,76 @@ const selectedCategories = (state: ITimeReportsOverviewState) => {
|
|||||||
.map(category => category.id) as string[];
|
.map(category => category.id) as string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectedUtilization = (state: ITimeReportsOverviewState) => {
|
||||||
|
return state.utilization
|
||||||
|
.filter(utilization => utilization.selected)
|
||||||
|
.map(utilization => utilization.id) as string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const allUtilization = (state: ITimeReportsOverviewState) => {
|
||||||
|
return state.utilization;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchReportingUtilization = createAsyncThunk(
|
||||||
|
'timeReportsOverview/fetchReportingUtilization',
|
||||||
|
async (_, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const utilization = [
|
||||||
|
{ id: 'under', name: 'Under-utilized (Under 90%)', selected: true },
|
||||||
|
{ id: 'optimal', name: 'Optimal-utilized (90%-110%)', selected: true },
|
||||||
|
{ id: 'over', name: 'Over-utilized (Over 110%)', selected: true },
|
||||||
|
];
|
||||||
|
return utilization;
|
||||||
|
} catch (error) {
|
||||||
|
let errorMessage = 'An error occurred while fetching utilization';
|
||||||
|
if (error instanceof Error) {
|
||||||
|
errorMessage = error.message;
|
||||||
|
}
|
||||||
|
return rejectWithValue(errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const fetchReportingMembers = createAsyncThunk(
|
||||||
|
'timeReportsOverview/fetchReportingMembers',
|
||||||
|
async (_, { rejectWithValue, getState }) => {
|
||||||
|
const state = getState() as { timeReportsOverviewReducer: ITimeReportsOverviewState };
|
||||||
|
const { timeReportsOverviewReducer } = state;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// If members array is empty (initial load), fetch all members without pagination
|
||||||
|
// Otherwise, use the selected members filter
|
||||||
|
let queryParams;
|
||||||
|
if (timeReportsOverviewReducer.members.length === 0) {
|
||||||
|
// Initial load - fetch all members with a large page size to avoid pagination
|
||||||
|
queryParams = {
|
||||||
|
size: 1000, // Large number to get all members
|
||||||
|
index: 1,
|
||||||
|
search: '',
|
||||||
|
field: 'name',
|
||||||
|
order: 'asc',
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Subsequent calls - use selected members
|
||||||
|
queryParams = selectedMembers(timeReportsOverviewReducer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await reportingApiService.getMembers(queryParams);
|
||||||
|
if (res.done) {
|
||||||
|
return res.body;
|
||||||
|
} else {
|
||||||
|
return rejectWithValue(res.message || 'Failed to fetch members');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
let errorMessage = 'An error occurred while fetching members';
|
||||||
|
if (error instanceof Error) {
|
||||||
|
errorMessage = error.message;
|
||||||
|
}
|
||||||
|
return rejectWithValue(errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export const fetchReportingTeams = createAsyncThunk(
|
export const fetchReportingTeams = createAsyncThunk(
|
||||||
'timeReportsOverview/fetchReportingTeams',
|
'timeReportsOverview/fetchReportingTeams',
|
||||||
async () => {
|
async () => {
|
||||||
@@ -141,6 +226,34 @@ const timeReportsOverviewSlice = createSlice({
|
|||||||
setArchived: (state, action: PayloadAction<boolean>) => {
|
setArchived: (state, action: PayloadAction<boolean>) => {
|
||||||
state.archived = action.payload;
|
state.archived = action.payload;
|
||||||
},
|
},
|
||||||
|
setSelectOrDeselectMember: (
|
||||||
|
state,
|
||||||
|
action: PayloadAction<{ id: string; selected: boolean }>
|
||||||
|
) => {
|
||||||
|
const member = state.members.find(member => member.id === action.payload.id);
|
||||||
|
if (member) {
|
||||||
|
member.selected = action.payload.selected;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setSelectOrDeselectAllMembers: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.members.forEach(member => {
|
||||||
|
member.selected = action.payload;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setSelectOrDeselectUtilization: (
|
||||||
|
state,
|
||||||
|
action: PayloadAction<{ id: string; selected: boolean }>
|
||||||
|
) => {
|
||||||
|
const utilization = state.utilization.find(u => u.id === action.payload.id);
|
||||||
|
if (utilization) {
|
||||||
|
utilization.selected = action.payload.selected;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setSelectOrDeselectAllUtilization: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.utilization.forEach(utilization => {
|
||||||
|
utilization.selected = action.payload;
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
extraReducers: builder => {
|
extraReducers: builder => {
|
||||||
builder.addCase(fetchReportingTeams.fulfilled, (state, action) => {
|
builder.addCase(fetchReportingTeams.fulfilled, (state, action) => {
|
||||||
@@ -185,6 +298,37 @@ const timeReportsOverviewSlice = createSlice({
|
|||||||
builder.addCase(fetchReportingProjects.rejected, state => {
|
builder.addCase(fetchReportingProjects.rejected, state => {
|
||||||
state.loadingProjects = false;
|
state.loadingProjects = false;
|
||||||
});
|
});
|
||||||
|
builder.addCase(fetchReportingMembers.fulfilled, (state, action) => {
|
||||||
|
const members = action.payload.members.map((member: any) => ({
|
||||||
|
id: member.id,
|
||||||
|
name: member.name,
|
||||||
|
selected: true,
|
||||||
|
avatar_url: member.avatar_url,
|
||||||
|
email: member.email,
|
||||||
|
}));
|
||||||
|
state.members = members;
|
||||||
|
state.loadingMembers = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.addCase(fetchReportingMembers.pending, state => {
|
||||||
|
state.loadingMembers = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.addCase(fetchReportingMembers.rejected, (state, action) => {
|
||||||
|
state.loadingMembers = false;
|
||||||
|
console.error('Error fetching members:', action.payload);
|
||||||
|
});
|
||||||
|
builder.addCase(fetchReportingUtilization.fulfilled, (state, action) => {
|
||||||
|
state.utilization = action.payload;
|
||||||
|
state.loadingUtilization = false;
|
||||||
|
});
|
||||||
|
builder.addCase(fetchReportingUtilization.pending, state => {
|
||||||
|
state.loadingUtilization = true;
|
||||||
|
});
|
||||||
|
builder.addCase(fetchReportingUtilization.rejected, (state, action) => {
|
||||||
|
state.loadingUtilization = false;
|
||||||
|
console.error('Error fetching utilization:', action.payload);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -197,6 +341,10 @@ export const {
|
|||||||
setSelectOrDeselectProject,
|
setSelectOrDeselectProject,
|
||||||
setSelectOrDeselectAllProjects,
|
setSelectOrDeselectAllProjects,
|
||||||
setSelectOrDeselectBillable,
|
setSelectOrDeselectBillable,
|
||||||
|
setSelectOrDeselectMember,
|
||||||
|
setSelectOrDeselectAllMembers,
|
||||||
|
setSelectOrDeselectUtilization,
|
||||||
|
setSelectOrDeselectAllUtilization,
|
||||||
setNoCategory,
|
setNoCategory,
|
||||||
setArchived,
|
setArchived,
|
||||||
} = timeReportsOverviewSlice.actions;
|
} = timeReportsOverviewSlice.actions;
|
||||||
|
|||||||
@@ -15,17 +15,19 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { reportingTimesheetApiService } from '@/api/reporting/reporting.timesheet.api.service';
|
import { reportingTimesheetApiService } from '@/api/reporting/reporting.timesheet.api.service';
|
||||||
import { IRPTTimeMember } from '@/types/reporting/reporting.types';
|
import { IRPTTimeMember } from '@/types/reporting/reporting.types';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ChartDataLabels);
|
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, ChartDataLabels);
|
||||||
|
|
||||||
|
interface MembersTimeSheetProps {
|
||||||
|
onTotalsUpdate: (totals: { total_time_logs: string; total_estimated_hours: string; total_utilization: string }) => void;
|
||||||
|
}
|
||||||
export interface MembersTimeSheetRef {
|
export interface MembersTimeSheetRef {
|
||||||
exportChart: () => void;
|
exportChart: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
const MembersTimeSheet = forwardRef<MembersTimeSheetRef, MembersTimeSheetProps>(({ onTotalsUpdate }, ref) => {
|
||||||
const { t } = useTranslation('time-report');
|
const { t } = useTranslation('time-report');
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const chartRef = React.useRef<ChartJS<'bar', string[], unknown>>(null);
|
const chartRef = React.useRef<ChartJS<'bar', string[], unknown>>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -35,8 +37,13 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
|||||||
loadingCategories,
|
loadingCategories,
|
||||||
projects: filterProjects,
|
projects: filterProjects,
|
||||||
loadingProjects,
|
loadingProjects,
|
||||||
|
members,
|
||||||
|
loadingMembers,
|
||||||
|
utilization,
|
||||||
|
loadingUtilization,
|
||||||
billable,
|
billable,
|
||||||
archived,
|
archived,
|
||||||
|
noCategory,
|
||||||
} = useAppSelector(state => state.timeReportsOverviewReducer);
|
} = useAppSelector(state => state.timeReportsOverviewReducer);
|
||||||
const { duration, dateRange } = useAppSelector(state => state.reportingReducer);
|
const { duration, dateRange } = useAppSelector(state => state.reportingReducer);
|
||||||
|
|
||||||
@@ -44,16 +51,40 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
|||||||
const [jsonData, setJsonData] = useState<IRPTTimeMember[]>([]);
|
const [jsonData, setJsonData] = useState<IRPTTimeMember[]>([]);
|
||||||
|
|
||||||
const labels = Array.isArray(jsonData) ? jsonData.map(item => item.name) : [];
|
const labels = Array.isArray(jsonData) ? jsonData.map(item => item.name) : [];
|
||||||
const dataValues = Array.isArray(jsonData)
|
const dataValues = Array.isArray(jsonData) ? jsonData.map(item => {
|
||||||
? jsonData.map(item => {
|
const loggedTimeInHours = parseFloat(item.logged_time || '0') / 3600;
|
||||||
const loggedTimeInHours = parseFloat(item.logged_time || '0') / 3600;
|
return loggedTimeInHours.toFixed(2);
|
||||||
return loggedTimeInHours.toFixed(2);
|
}) : [];
|
||||||
})
|
const colors = Array.isArray(jsonData) ? jsonData.map(item => {
|
||||||
: [];
|
const utilizationPercent = parseFloat(item.utilization_percent || '0');
|
||||||
const colors = Array.isArray(jsonData) ? jsonData.map(item => item.color_code) : [];
|
|
||||||
|
if (utilizationPercent < 90) {
|
||||||
|
return '#faad14'; // Orange for under-utilized (< 90%)
|
||||||
|
} else if (utilizationPercent <= 110) {
|
||||||
|
return '#52c41a'; // Green for optimal utilization (90-110%)
|
||||||
|
} else {
|
||||||
|
return '#ef4444'; // Red for over-utilized (> 110%)
|
||||||
|
}
|
||||||
|
}) : [];
|
||||||
|
|
||||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
|
|
||||||
|
// Helper function to format hours to "X hours Y mins"
|
||||||
|
const formatHours = (decimalHours: number) => {
|
||||||
|
const wholeHours = Math.floor(decimalHours);
|
||||||
|
const minutes = Math.round((decimalHours - wholeHours) * 60);
|
||||||
|
|
||||||
|
if (wholeHours === 0 && minutes === 0) {
|
||||||
|
return '0 mins';
|
||||||
|
} else if (wholeHours === 0) {
|
||||||
|
return `${minutes} mins`;
|
||||||
|
} else if (minutes === 0) {
|
||||||
|
return `${wholeHours} ${wholeHours === 1 ? 'hour' : 'hours'}`;
|
||||||
|
} else {
|
||||||
|
return `${wholeHours} ${wholeHours === 1 ? 'hour' : 'hours'} ${minutes} mins`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Chart data
|
// Chart data
|
||||||
const data = {
|
const data = {
|
||||||
labels,
|
labels,
|
||||||
@@ -78,27 +109,96 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
|||||||
offset: 20,
|
offset: 20,
|
||||||
textStrokeColor: 'black',
|
textStrokeColor: 'black',
|
||||||
textStrokeWidth: 4,
|
textStrokeWidth: 4,
|
||||||
|
formatter: function(value: string) {
|
||||||
|
const hours = parseFloat(value);
|
||||||
|
const wholeHours = Math.floor(hours);
|
||||||
|
const minutes = Math.round((hours - wholeHours) * 60);
|
||||||
|
|
||||||
|
if (wholeHours === 0 && minutes === 0) {
|
||||||
|
return '0 mins';
|
||||||
|
} else if (wholeHours === 0) {
|
||||||
|
return `${minutes} mins`;
|
||||||
|
} else if (minutes === 0) {
|
||||||
|
return `${wholeHours} ${wholeHours === 1 ? 'hour' : 'hours'}`;
|
||||||
|
} else {
|
||||||
|
return `${wholeHours} ${wholeHours === 1 ? 'hour' : 'hours'} ${minutes} mins`;
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
display: false,
|
display: false,
|
||||||
position: 'top' as const,
|
position: 'top' as const,
|
||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
|
// Basic styling
|
||||||
|
backgroundColor: themeMode === 'dark' ? 'rgba(0, 0, 0, 0.9)' : 'rgba(255, 255, 255, 0.9)',
|
||||||
|
titleColor: themeMode === 'dark' ? '#ffffff' : '#000000',
|
||||||
|
bodyColor: themeMode === 'dark' ? '#ffffff' : '#000000',
|
||||||
|
borderColor: themeMode === 'dark' ? '#4a5568' : '#e2e8f0',
|
||||||
|
cornerRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
|
||||||
|
// Remove colored squares
|
||||||
|
displayColors: false,
|
||||||
|
|
||||||
|
// Positioning - better alignment for horizontal bar chart
|
||||||
|
xAlign: 'left' as const,
|
||||||
|
yAlign: 'center' as const,
|
||||||
|
|
||||||
callbacks: {
|
callbacks: {
|
||||||
|
// Customize the title (member name)
|
||||||
|
title: function (context: any) {
|
||||||
|
const idx = context[0].dataIndex;
|
||||||
|
const member = jsonData[idx];
|
||||||
|
return `👤 ${member?.name || 'Unknown Member'}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Customize the label content
|
||||||
label: function (context: any) {
|
label: function (context: any) {
|
||||||
const idx = context.dataIndex;
|
const idx = context.dataIndex;
|
||||||
const member = jsonData[idx];
|
const member = jsonData[idx];
|
||||||
const hours = member?.utilized_hours || '0.00';
|
const hours = parseFloat(member?.utilized_hours || '0');
|
||||||
const percent = member?.utilization_percent || '0.00';
|
const percent = parseFloat(member?.utilization_percent || '0.00');
|
||||||
const overUnder = member?.over_under_utilized_hours || '0.00';
|
const overUnder = parseFloat(member?.over_under_utilized_hours || '0');
|
||||||
|
|
||||||
|
// Color indicators based on utilization state
|
||||||
|
let statusText = '';
|
||||||
|
let criteriaText = '';
|
||||||
|
switch (member.utilization_state) {
|
||||||
|
case 'under':
|
||||||
|
statusText = '🟠 Under-Utilized';
|
||||||
|
criteriaText = '(< 90%)';
|
||||||
|
break;
|
||||||
|
case 'optimal':
|
||||||
|
statusText = '🟢 Optimally Utilized';
|
||||||
|
criteriaText = '(90% - 110%)';
|
||||||
|
break;
|
||||||
|
case 'over':
|
||||||
|
statusText = '🔴 Over-Utilized';
|
||||||
|
criteriaText = '(> 110%)';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
statusText = '⚪ Unknown';
|
||||||
|
criteriaText = '';
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
`${context.dataset.label}: ${hours} h`,
|
`⏱️ ${context.dataset.label}: ${formatHours(hours)}`,
|
||||||
`Utilization: ${percent}%`,
|
`📊 Utilization: ${percent.toFixed(1)}%`,
|
||||||
`Over/Under Utilized: ${overUnder} h`,
|
`${statusText} ${criteriaText}`,
|
||||||
|
`📈 Variance: ${formatHours(Math.abs(overUnder))}${overUnder < 0 ? ' (under)' : overUnder > 0 ? ' (over)' : ''}`
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
},
|
|
||||||
},
|
// Add a footer with additional info
|
||||||
|
footer: function (context: any) {
|
||||||
|
const idx = context[0].dataIndex;
|
||||||
|
const member = jsonData[idx];
|
||||||
|
const loggedTime = parseFloat(member?.logged_time || '0') / 3600;
|
||||||
|
return `📊 Total Logged: ${formatHours(loggedTime)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
backgroundColor: 'black',
|
backgroundColor: 'black',
|
||||||
indexAxis: 'y' as const,
|
indexAxis: 'y' as const,
|
||||||
@@ -142,30 +242,93 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
|||||||
const selectedTeams = teams.filter(team => team.selected);
|
const selectedTeams = teams.filter(team => team.selected);
|
||||||
const selectedProjects = filterProjects.filter(project => project.selected);
|
const selectedProjects = filterProjects.filter(project => project.selected);
|
||||||
const selectedCategories = categories.filter(category => category.selected);
|
const selectedCategories = categories.filter(category => category.selected);
|
||||||
|
const selectedMembers = members.filter(member => member.selected);
|
||||||
|
const selectedUtilization = utilization.filter(item => item.selected);
|
||||||
|
|
||||||
|
// Format dates using date-fns
|
||||||
|
const formattedDateRange = dateRange ? [
|
||||||
|
format(new Date(dateRange[0]), 'yyyy-MM-dd'),
|
||||||
|
format(new Date(dateRange[1]), 'yyyy-MM-dd')
|
||||||
|
] : undefined;
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
teams: selectedTeams.map(t => t.id),
|
teams: selectedTeams.map(t => t.id),
|
||||||
projects: selectedProjects.map(project => project.id),
|
projects: selectedProjects.map(project => project.id),
|
||||||
categories: selectedCategories.map(category => category.id),
|
categories: selectedCategories.map(category => category.id),
|
||||||
|
members: selectedMembers.map(member => member.id),
|
||||||
|
utilization: selectedUtilization.map(item => item.id),
|
||||||
duration,
|
duration,
|
||||||
date_range: dateRange,
|
date_range: formattedDateRange,
|
||||||
billable,
|
billable,
|
||||||
|
noCategory,
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await reportingTimesheetApiService.getMemberTimeSheets(body, archived);
|
const res = await reportingTimesheetApiService.getMemberTimeSheets(body, archived);
|
||||||
|
|
||||||
if (res.done) {
|
if (res.done) {
|
||||||
setJsonData(res.body || []);
|
// Ensure filteredRows is always an array, even if API returns null/undefined
|
||||||
|
setJsonData(res.body?.filteredRows || []);
|
||||||
|
|
||||||
|
const totalsRaw = res.body?.totals || {};
|
||||||
|
const totals = {
|
||||||
|
total_time_logs: totalsRaw.total_time_logs ?? "0",
|
||||||
|
total_estimated_hours: totalsRaw.total_estimated_hours ?? "0",
|
||||||
|
total_utilization: totalsRaw.total_utilization ?? "0",
|
||||||
|
};
|
||||||
|
onTotalsUpdate(totals);
|
||||||
|
} else {
|
||||||
|
// Handle API error case
|
||||||
|
setJsonData([]);
|
||||||
|
onTotalsUpdate({
|
||||||
|
total_time_logs: "0",
|
||||||
|
total_estimated_hours: "0",
|
||||||
|
total_utilization: "0"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error fetching chart data:', error);
|
||||||
logger.error('Error fetching chart data:', error);
|
logger.error('Error fetching chart data:', error);
|
||||||
|
// Reset data on error
|
||||||
|
setJsonData([]);
|
||||||
|
onTotalsUpdate({
|
||||||
|
total_time_logs: "0",
|
||||||
|
total_estimated_hours: "0",
|
||||||
|
total_utilization: "0"
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Create stable references for selected items to prevent unnecessary re-renders
|
||||||
|
const selectedTeamIds = React.useMemo(() =>
|
||||||
|
teams.filter(team => team.selected).map(t => t.id).join(','),
|
||||||
|
[teams]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedProjectIds = React.useMemo(() =>
|
||||||
|
filterProjects.filter(project => project.selected).map(p => p.id).join(','),
|
||||||
|
[filterProjects]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedCategoryIds = React.useMemo(() =>
|
||||||
|
categories.filter(category => category.selected).map(c => c.id).join(','),
|
||||||
|
[categories]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedMemberIds = React.useMemo(() =>
|
||||||
|
members.filter(member => member.selected).map(m => m.id).join(','),
|
||||||
|
[members]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedUtilizationIds = React.useMemo(() =>
|
||||||
|
utilization.filter(item => item.selected).map(u => u.id).join(','),
|
||||||
|
[utilization]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchChartData();
|
fetchChartData();
|
||||||
}, [dispatch, duration, dateRange, billable, archived, teams, filterProjects, categories]);
|
}, [duration, dateRange, billable, archived, noCategory, selectedTeamIds, selectedProjectIds, selectedCategoryIds, selectedMemberIds, selectedUtilizationIds]);
|
||||||
|
|
||||||
const exportChart = () => {
|
const exportChart = () => {
|
||||||
if (chartRef.current) {
|
if (chartRef.current) {
|
||||||
@@ -197,7 +360,7 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
exportChart,
|
exportChart
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Card, Flex, Segmented } from '@/shared/antd-imports';
|
import { Card, Flex, Segmented } from '@/shared/antd-imports';
|
||||||
import TimeReportPageHeader from '@/pages/reporting/timeReports/page-header/time-report-page-header';
|
import TimeReportPageHeader from '@/components/reporting/time-reports/page-header/TimeReportPageHeader';
|
||||||
import EstimatedVsActualTimeSheet, {
|
import EstimatedVsActualTimeSheet, {
|
||||||
EstimatedVsActualTimeSheetRef,
|
EstimatedVsActualTimeSheetRef,
|
||||||
} from '@/pages/reporting/time-reports/estimated-vs-actual-time-sheet/estimated-vs-actual-time-sheet';
|
} from '@/pages/reporting/time-reports/estimated-vs-actual-time-sheet/estimated-vs-actual-time-sheet';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||||
import TimeReportingRightHeader from './timeReportingRightHeader/TimeReportingRightHeader';
|
import TimeReportingRightHeader from '@/components/reporting/time-reports/right-header/TimeReportingRightHeader';
|
||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
|
|
||||||
const EstimatedVsActualTimeReports = () => {
|
const EstimatedVsActualTimeReports = () => {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Card, Flex } from '@/shared/antd-imports';
|
import { Card, Flex } from '@/shared/antd-imports';
|
||||||
import TimeReportPageHeader from '@/pages/reporting/timeReports/page-header/time-report-page-header';
|
import TimeReportPageHeader from '@/components/reporting/time-reports/page-header/TimeReportPageHeader';
|
||||||
import MembersTimeSheet, {
|
import MembersTimeSheet, {
|
||||||
MembersTimeSheetRef,
|
MembersTimeSheetRef,
|
||||||
} from '@/pages/reporting/time-reports/members-time-sheet/members-time-sheet';
|
} from '@/pages/reporting/time-reports/members-time-sheet/members-time-sheet';
|
||||||
import TimeReportingRightHeader from './timeReportingRightHeader/TimeReportingRightHeader';
|
import TimeReportingRightHeader from '@/components/reporting/time-reports/right-header/TimeReportingRightHeader';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
@@ -20,10 +20,20 @@ const MembersTimeReports = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTotalsUpdate = (totals: {
|
||||||
|
total_time_logs: string;
|
||||||
|
total_estimated_hours: string;
|
||||||
|
total_utilization: string;
|
||||||
|
}) => {
|
||||||
|
// Handle totals update if needed
|
||||||
|
// This could be used to display totals in the UI or pass to parent components
|
||||||
|
console.log('Totals updated:', totals);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex vertical>
|
<Flex vertical>
|
||||||
<TimeReportingRightHeader
|
<TimeReportingRightHeader
|
||||||
title={t('Members Time Sheet')}
|
title={t('membersTimeSheet')}
|
||||||
exportType={[{ key: 'png', label: 'PNG' }]}
|
exportType={[{ key: 'png', label: 'PNG' }]}
|
||||||
export={handleExport}
|
export={handleExport}
|
||||||
/>
|
/>
|
||||||
@@ -43,7 +53,7 @@ const MembersTimeReports = () => {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MembersTimeSheet ref={chartRef} />
|
<MembersTimeSheet ref={chartRef} onTotalsUpdate={handleTotalsUpdate} />
|
||||||
</Card>
|
</Card>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import TimeReportPageHeader from '@/pages/reporting/timeReports/page-header/time-report-page-header';
|
import TimeReportPageHeader from '@/components/reporting/time-reports/page-header/TimeReportPageHeader';
|
||||||
import { Flex } from '@/shared/antd-imports';
|
import { Flex } from '@/shared/antd-imports';
|
||||||
import TimeSheetTable from '@/pages/reporting/time-reports/time-sheet-table/time-sheet-table';
|
import TimeSheetTable from '@/pages/reporting/time-reports/time-sheet-table/time-sheet-table';
|
||||||
import TimeReportingRightHeader from './timeReportingRightHeader/TimeReportingRightHeader';
|
import TimeReportingRightHeader from '@/components/reporting/time-reports/right-header/TimeReportingRightHeader';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import { setSelectOrDeselectBillable } from '@/features/reporting/time-reports/time-reports-overview.slice';
|
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
|
||||||
import { CaretDownFilled } from '@/shared/antd-imports';
|
|
||||||
import { Button, Checkbox, Dropdown, MenuProps } from '@/shared/antd-imports';
|
|
||||||
import React from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
const Billable: React.FC = () => {
|
|
||||||
const { t } = useTranslation('time-report');
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const { billable } = useAppSelector(state => state.timeReportsOverviewReducer);
|
|
||||||
|
|
||||||
// Dropdown items for the menu
|
|
||||||
const menuItems: MenuProps['items'] = [
|
|
||||||
{
|
|
||||||
key: 'search',
|
|
||||||
label: <Checkbox checked={billable.billable}>{t('billable')}</Checkbox>,
|
|
||||||
onClick: () => {
|
|
||||||
dispatch(setSelectOrDeselectBillable({ ...billable, billable: !billable.billable }));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'selectAll',
|
|
||||||
label: <Checkbox checked={billable.nonBillable}>{t('nonBillable')}</Checkbox>,
|
|
||||||
onClick: () => {
|
|
||||||
dispatch(setSelectOrDeselectBillable({ ...billable, nonBillable: !billable.nonBillable }));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Dropdown
|
|
||||||
menu={{ items: menuItems }}
|
|
||||||
placement="bottomLeft"
|
|
||||||
trigger={['click']}
|
|
||||||
overlayStyle={{ maxHeight: '330px', overflowY: 'auto' }}
|
|
||||||
>
|
|
||||||
<Button>
|
|
||||||
{t('billable')} <CaretDownFilled />
|
|
||||||
</Button>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Billable;
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
import {
|
|
||||||
fetchReportingProjects,
|
|
||||||
setNoCategory,
|
|
||||||
setSelectOrDeselectAllCategories,
|
|
||||||
setSelectOrDeselectCategory,
|
|
||||||
} from '@/features/reporting/time-reports/time-reports-overview.slice';
|
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
|
||||||
import { CaretDownFilled } from '@/shared/antd-imports';
|
|
||||||
import { Button, Card, Checkbox, Divider, Dropdown, Input, theme } from '@/shared/antd-imports';
|
|
||||||
import { CheckboxChangeEvent } from 'antd/es/checkbox';
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
const Categories: React.FC = () => {
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const [searchText, setSearchText] = useState('');
|
|
||||||
const [selectAll, setSelectAll] = useState(true);
|
|
||||||
const { t } = useTranslation('time-report');
|
|
||||||
const [dropdownVisible, setDropdownVisible] = useState(false);
|
|
||||||
const { categories, loadingCategories, noCategory } = useAppSelector(
|
|
||||||
state => state.timeReportsOverviewReducer
|
|
||||||
);
|
|
||||||
const { token } = theme.useToken();
|
|
||||||
|
|
||||||
const filteredItems = categories.filter(item =>
|
|
||||||
item.name?.toLowerCase().includes(searchText.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle checkbox change for individual items
|
|
||||||
const handleCheckboxChange = async (key: string, checked: boolean) => {
|
|
||||||
await dispatch(setSelectOrDeselectCategory({ id: key, selected: checked }));
|
|
||||||
await dispatch(fetchReportingProjects());
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle "Select All" checkbox change
|
|
||||||
const handleSelectAllChange = async (e: CheckboxChangeEvent) => {
|
|
||||||
const isChecked = e.target.checked;
|
|
||||||
setSelectAll(isChecked);
|
|
||||||
await dispatch(setNoCategory(isChecked));
|
|
||||||
await dispatch(setSelectOrDeselectAllCategories(isChecked));
|
|
||||||
await dispatch(fetchReportingProjects());
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNoCategoryChange = async (checked: boolean) => {
|
|
||||||
await dispatch(setNoCategory(checked));
|
|
||||||
await dispatch(fetchReportingProjects());
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Dropdown
|
|
||||||
menu={undefined}
|
|
||||||
placement="bottomLeft"
|
|
||||||
trigger={['click']}
|
|
||||||
dropdownRender={() => (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: token.colorBgContainer,
|
|
||||||
borderRadius: token.borderRadius,
|
|
||||||
boxShadow: token.boxShadow,
|
|
||||||
padding: '4px 0',
|
|
||||||
maxHeight: '330px',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ padding: '8px', flexShrink: 0 }}>
|
|
||||||
<Input
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
placeholder={t('searchByCategory')}
|
|
||||||
value={searchText}
|
|
||||||
onChange={e => setSearchText(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{categories.length > 0 && (
|
|
||||||
<div style={{ padding: '0 12px', flexShrink: 0 }}>
|
|
||||||
<Checkbox
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
onChange={handleSelectAllChange}
|
|
||||||
checked={selectAll}
|
|
||||||
>
|
|
||||||
{t('selectAll')}
|
|
||||||
</Checkbox>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{ padding: '8px 12px 4px 12px', flexShrink: 0 }}>
|
|
||||||
<Checkbox
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
checked={noCategory}
|
|
||||||
onChange={e => handleNoCategoryChange(e.target.checked)}
|
|
||||||
>
|
|
||||||
{t('noCategory')}
|
|
||||||
</Checkbox>
|
|
||||||
</div>
|
|
||||||
<Divider style={{ margin: '4px 0', flexShrink: 0 }} />
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
overflowY: 'auto',
|
|
||||||
flex: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{filteredItems.length > 0 ? (
|
|
||||||
filteredItems.map(item => (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
style={{
|
|
||||||
padding: '8px 12px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
checked={item.selected}
|
|
||||||
onChange={e => handleCheckboxChange(item.id || '', e.target.checked)}
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</Checkbox>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div style={{ padding: '8px 12px' }}>{t('noCategories')}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
onOpenChange={visible => {
|
|
||||||
setDropdownVisible(visible);
|
|
||||||
if (!visible) {
|
|
||||||
setSearchText('');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button loading={loadingCategories}>
|
|
||||||
{t('categories')} <CaretDownFilled />
|
|
||||||
</Button>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Categories;
|
|
||||||
@@ -1,680 +0,0 @@
|
|||||||
import {
|
|
||||||
setSelectOrDeselectAllProjects,
|
|
||||||
setSelectOrDeselectProject,
|
|
||||||
} from '@/features/reporting/time-reports/time-reports-overview.slice';
|
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
|
||||||
import {
|
|
||||||
CaretDownFilled,
|
|
||||||
SearchOutlined,
|
|
||||||
ClearOutlined,
|
|
||||||
DownOutlined,
|
|
||||||
RightOutlined,
|
|
||||||
FilterOutlined,
|
|
||||||
} from '@/shared/antd-imports';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Checkbox,
|
|
||||||
Divider,
|
|
||||||
Dropdown,
|
|
||||||
Input,
|
|
||||||
theme,
|
|
||||||
Typography,
|
|
||||||
Badge,
|
|
||||||
Collapse,
|
|
||||||
Select,
|
|
||||||
Space,
|
|
||||||
Tooltip,
|
|
||||||
Empty,
|
|
||||||
} from '@/shared/antd-imports';
|
|
||||||
import { CheckboxChangeEvent } from 'antd/es/checkbox';
|
|
||||||
import React, { useState, useMemo, useCallback } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { ISelectableProject } from '@/types/reporting/reporting-filters.types';
|
|
||||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
|
||||||
|
|
||||||
const { Panel } = Collapse;
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
type GroupByOption = 'none' | 'category' | 'team' | 'status';
|
|
||||||
|
|
||||||
interface ProjectGroup {
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
color?: string;
|
|
||||||
projects: ISelectableProject[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const Projects: React.FC = () => {
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const [searchText, setSearchText] = useState('');
|
|
||||||
const [groupBy, setGroupBy] = useState<GroupByOption>('none');
|
|
||||||
const [showSelectedOnly, setShowSelectedOnly] = useState(false);
|
|
||||||
const [expandedGroups, setExpandedGroups] = useState<string[]>([]);
|
|
||||||
const { t } = useTranslation('time-report');
|
|
||||||
const [dropdownVisible, setDropdownVisible] = useState(false);
|
|
||||||
const { projects, loadingProjects } = useAppSelector(state => state.timeReportsOverviewReducer);
|
|
||||||
const { token } = theme.useToken();
|
|
||||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
|
||||||
|
|
||||||
// Theme-aware color utilities
|
|
||||||
const getThemeAwareColor = useCallback(
|
|
||||||
(lightColor: string, darkColor: string) => {
|
|
||||||
return themeWiseColor(lightColor, darkColor, themeMode);
|
|
||||||
},
|
|
||||||
[themeMode]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Enhanced color processing for project/group colors
|
|
||||||
const processColor = useCallback(
|
|
||||||
(color: string | undefined, fallback?: string) => {
|
|
||||||
if (!color) return fallback || token.colorPrimary;
|
|
||||||
|
|
||||||
// If it's a hex color, ensure it has good contrast in both themes
|
|
||||||
if (color.startsWith('#')) {
|
|
||||||
// For dark mode, lighten dark colors and darken light colors for better visibility
|
|
||||||
if (themeMode === 'dark') {
|
|
||||||
// Simple brightness adjustment for dark mode
|
|
||||||
const hex = color.replace('#', '');
|
|
||||||
const r = parseInt(hex.substr(0, 2), 16);
|
|
||||||
const g = parseInt(hex.substr(2, 2), 16);
|
|
||||||
const b = parseInt(hex.substr(4, 2), 16);
|
|
||||||
|
|
||||||
// Calculate brightness (0-255)
|
|
||||||
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
|
||||||
|
|
||||||
// If color is too dark in dark mode, lighten it
|
|
||||||
if (brightness < 100) {
|
|
||||||
const factor = 1.5;
|
|
||||||
const newR = Math.min(255, Math.floor(r * factor));
|
|
||||||
const newG = Math.min(255, Math.floor(g * factor));
|
|
||||||
const newB = Math.min(255, Math.floor(b * factor));
|
|
||||||
return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// For light mode, ensure colors aren't too light
|
|
||||||
const hex = color.replace('#', '');
|
|
||||||
const r = parseInt(hex.substr(0, 2), 16);
|
|
||||||
const g = parseInt(hex.substr(2, 2), 16);
|
|
||||||
const b = parseInt(hex.substr(4, 2), 16);
|
|
||||||
|
|
||||||
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
|
||||||
|
|
||||||
// If color is too light in light mode, darken it
|
|
||||||
if (brightness > 200) {
|
|
||||||
const factor = 0.7;
|
|
||||||
const newR = Math.floor(r * factor);
|
|
||||||
const newG = Math.floor(g * factor);
|
|
||||||
const newB = Math.floor(b * factor);
|
|
||||||
return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return color;
|
|
||||||
},
|
|
||||||
[themeMode, token.colorPrimary]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoized filtered projects
|
|
||||||
const filteredProjects = useMemo(() => {
|
|
||||||
let filtered = projects.filter(item =>
|
|
||||||
item.name?.toLowerCase().includes(searchText.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
if (showSelectedOnly) {
|
|
||||||
filtered = filtered.filter(item => item.selected);
|
|
||||||
}
|
|
||||||
|
|
||||||
return filtered;
|
|
||||||
}, [projects, searchText, showSelectedOnly]);
|
|
||||||
|
|
||||||
// Memoized grouped projects
|
|
||||||
const groupedProjects = useMemo(() => {
|
|
||||||
if (groupBy === 'none') {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
key: 'all',
|
|
||||||
name: t('projects'),
|
|
||||||
projects: filteredProjects,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const groups: { [key: string]: ProjectGroup } = {};
|
|
||||||
|
|
||||||
filteredProjects.forEach(project => {
|
|
||||||
let groupKey: string;
|
|
||||||
let groupName: string;
|
|
||||||
let groupColor: string | undefined;
|
|
||||||
|
|
||||||
switch (groupBy) {
|
|
||||||
case 'category':
|
|
||||||
groupKey = (project as any).category_id || 'uncategorized';
|
|
||||||
groupName = (project as any).category_name || t('noCategory');
|
|
||||||
groupColor = (project as any).category_color;
|
|
||||||
break;
|
|
||||||
case 'team':
|
|
||||||
groupKey = (project as any).team_id || 'no-team';
|
|
||||||
groupName = (project as any).team_name || t('ungrouped');
|
|
||||||
groupColor = (project as any).team_color;
|
|
||||||
break;
|
|
||||||
case 'status':
|
|
||||||
groupKey = (project as any).status_id || 'no-status';
|
|
||||||
groupName = (project as any).status_name || t('ungrouped');
|
|
||||||
groupColor = (project as any).status_color;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
groupKey = 'all';
|
|
||||||
groupName = t('projects');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!groups[groupKey]) {
|
|
||||||
groups[groupKey] = {
|
|
||||||
key: groupKey,
|
|
||||||
name: groupName,
|
|
||||||
color: processColor(groupColor),
|
|
||||||
projects: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
groups[groupKey].projects.push(project);
|
|
||||||
});
|
|
||||||
|
|
||||||
return Object.values(groups).sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
}, [filteredProjects, groupBy, t, processColor]);
|
|
||||||
|
|
||||||
// Selected projects count
|
|
||||||
const selectedCount = useMemo(() => projects.filter(p => p.selected).length, [projects]);
|
|
||||||
|
|
||||||
const allSelected = useMemo(
|
|
||||||
() => filteredProjects.length > 0 && filteredProjects.every(p => p.selected),
|
|
||||||
[filteredProjects]
|
|
||||||
);
|
|
||||||
|
|
||||||
const indeterminate = useMemo(
|
|
||||||
() => filteredProjects.some(p => p.selected) && !allSelected,
|
|
||||||
[filteredProjects, allSelected]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize group by options
|
|
||||||
const groupByOptions = useMemo(
|
|
||||||
() => [
|
|
||||||
{ value: 'none', label: t('groupByNone') },
|
|
||||||
{ value: 'category', label: t('groupByCategory') },
|
|
||||||
{ value: 'team', label: t('groupByTeam') },
|
|
||||||
{ value: 'status', label: t('groupByStatus') },
|
|
||||||
],
|
|
||||||
[t]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize dropdown styles to prevent recalculation on every render
|
|
||||||
const dropdownStyles = useMemo(
|
|
||||||
() => ({
|
|
||||||
dropdown: {
|
|
||||||
background: token.colorBgContainer,
|
|
||||||
borderRadius: token.borderRadius,
|
|
||||||
boxShadow: token.boxShadowSecondary,
|
|
||||||
border: `1px solid ${token.colorBorder}`,
|
|
||||||
},
|
|
||||||
groupHeader: {
|
|
||||||
backgroundColor: getThemeAwareColor(token.colorFillTertiary, token.colorFillQuaternary),
|
|
||||||
borderRadius: token.borderRadiusSM,
|
|
||||||
padding: '8px 12px',
|
|
||||||
marginBottom: '4px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
border: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
|
|
||||||
},
|
|
||||||
projectItem: {
|
|
||||||
padding: '8px 12px',
|
|
||||||
borderRadius: token.borderRadiusSM,
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
cursor: 'pointer',
|
|
||||||
border: `1px solid transparent`,
|
|
||||||
},
|
|
||||||
toggleIcon: {
|
|
||||||
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
|
|
||||||
fontSize: '12px',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
},
|
|
||||||
expandedToggleIcon: {
|
|
||||||
color: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
|
|
||||||
fontSize: '12px',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[token, getThemeAwareColor]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize search placeholder and clear tooltip
|
|
||||||
const searchPlaceholder = useMemo(() => t('searchByProject'), [t]);
|
|
||||||
const clearTooltip = useMemo(() => t('clearSearch'), [t]);
|
|
||||||
const showSelectedTooltip = useMemo(() => t('showSelected'), [t]);
|
|
||||||
const selectAllText = useMemo(() => t('selectAll'), [t]);
|
|
||||||
const projectsSelectedText = useMemo(() => t('projectsSelected'), [t]);
|
|
||||||
const noProjectsText = useMemo(() => t('noProjects'), [t]);
|
|
||||||
const noDataText = useMemo(() => t('noData'), [t]);
|
|
||||||
const expandAllText = useMemo(() => t('expandAll'), [t]);
|
|
||||||
const collapseAllText = useMemo(() => t('collapseAll'), [t]);
|
|
||||||
|
|
||||||
// Handle checkbox change for individual items
|
|
||||||
const handleCheckboxChange = useCallback(
|
|
||||||
(key: string, checked: boolean) => {
|
|
||||||
dispatch(setSelectOrDeselectProject({ id: key, selected: checked }));
|
|
||||||
},
|
|
||||||
[dispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle "Select All" checkbox change
|
|
||||||
const handleSelectAllChange = useCallback(
|
|
||||||
(e: CheckboxChangeEvent) => {
|
|
||||||
const isChecked = e.target.checked;
|
|
||||||
dispatch(setSelectOrDeselectAllProjects(isChecked));
|
|
||||||
},
|
|
||||||
[dispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clear search
|
|
||||||
const clearSearch = useCallback(() => {
|
|
||||||
setSearchText('');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Toggle group expansion
|
|
||||||
const toggleGroupExpansion = useCallback((groupKey: string) => {
|
|
||||||
setExpandedGroups(prev =>
|
|
||||||
prev.includes(groupKey) ? prev.filter(key => key !== groupKey) : [...prev, groupKey]
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Expand/Collapse all groups
|
|
||||||
const toggleAllGroups = useCallback(
|
|
||||||
(expand: boolean) => {
|
|
||||||
if (expand) {
|
|
||||||
setExpandedGroups(groupedProjects.map(g => g.key));
|
|
||||||
} else {
|
|
||||||
setExpandedGroups([]);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[groupedProjects]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Render project group
|
|
||||||
const renderProjectGroup = (group: ProjectGroup) => {
|
|
||||||
const isExpanded = expandedGroups.includes(group.key) || groupBy === 'none';
|
|
||||||
const groupSelectedCount = group.projects.filter(p => p.selected).length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={group.key} style={{ marginBottom: '8px' }}>
|
|
||||||
{groupBy !== 'none' && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
...dropdownStyles.groupHeader,
|
|
||||||
backgroundColor: isExpanded
|
|
||||||
? getThemeAwareColor(token.colorFillSecondary, token.colorFillTertiary)
|
|
||||||
: dropdownStyles.groupHeader.backgroundColor,
|
|
||||||
}}
|
|
||||||
onClick={() => toggleGroupExpansion(group.key)}
|
|
||||||
onMouseEnter={e => {
|
|
||||||
e.currentTarget.style.backgroundColor = getThemeAwareColor(
|
|
||||||
token.colorFillSecondary,
|
|
||||||
token.colorFillTertiary
|
|
||||||
);
|
|
||||||
e.currentTarget.style.borderColor = getThemeAwareColor(
|
|
||||||
token.colorBorder,
|
|
||||||
token.colorBorderSecondary
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
onMouseLeave={e => {
|
|
||||||
e.currentTarget.style.backgroundColor = isExpanded
|
|
||||||
? getThemeAwareColor(token.colorFillSecondary, token.colorFillTertiary)
|
|
||||||
: dropdownStyles.groupHeader.backgroundColor;
|
|
||||||
e.currentTarget.style.borderColor = getThemeAwareColor(
|
|
||||||
token.colorBorderSecondary,
|
|
||||||
token.colorBorder
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Space>
|
|
||||||
{isExpanded ? (
|
|
||||||
<DownOutlined style={dropdownStyles.expandedToggleIcon} />
|
|
||||||
) : (
|
|
||||||
<RightOutlined style={dropdownStyles.toggleIcon} />
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '12px',
|
|
||||||
height: '12px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: group.color || processColor(undefined, token.colorPrimary),
|
|
||||||
flexShrink: 0,
|
|
||||||
border: `1px solid ${getThemeAwareColor('rgba(0,0,0,0.1)', 'rgba(255,255,255,0.2)')}`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
strong
|
|
||||||
style={{
|
|
||||||
color: getThemeAwareColor(token.colorText, token.colorTextBase),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{group.name}
|
|
||||||
</Text>
|
|
||||||
<Badge
|
|
||||||
count={groupSelectedCount}
|
|
||||||
size="small"
|
|
||||||
style={{
|
|
||||||
backgroundColor: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
|
|
||||||
color: getThemeAwareColor('#fff', token.colorTextLightSolid),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isExpanded && (
|
|
||||||
<div style={{ paddingLeft: groupBy !== 'none' ? '24px' : '0' }}>
|
|
||||||
{group.projects.map(project => (
|
|
||||||
<div
|
|
||||||
key={project.id}
|
|
||||||
style={dropdownStyles.projectItem}
|
|
||||||
onMouseEnter={e => {
|
|
||||||
e.currentTarget.style.backgroundColor = getThemeAwareColor(
|
|
||||||
token.colorFillAlter,
|
|
||||||
token.colorFillQuaternary
|
|
||||||
);
|
|
||||||
e.currentTarget.style.borderColor = getThemeAwareColor(
|
|
||||||
token.colorBorderSecondary,
|
|
||||||
token.colorBorder
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
onMouseLeave={e => {
|
|
||||||
e.currentTarget.style.backgroundColor = 'transparent';
|
|
||||||
e.currentTarget.style.borderColor = 'transparent';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
checked={project.selected}
|
|
||||||
onChange={e => handleCheckboxChange(project.id || '', e.target.checked)}
|
|
||||||
>
|
|
||||||
<Space>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '8px',
|
|
||||||
height: '8px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: processColor(
|
|
||||||
(project as any).color_code,
|
|
||||||
token.colorPrimary
|
|
||||||
),
|
|
||||||
flexShrink: 0,
|
|
||||||
border: `1px solid ${getThemeAwareColor('rgba(0,0,0,0.1)', 'rgba(255,255,255,0.2)')}`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: getThemeAwareColor(token.colorText, token.colorTextBase),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{project.name}
|
|
||||||
</Text>
|
|
||||||
</Space>
|
|
||||||
</Checkbox>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Dropdown
|
|
||||||
menu={undefined}
|
|
||||||
placement="bottomLeft"
|
|
||||||
trigger={['click']}
|
|
||||||
open={dropdownVisible}
|
|
||||||
dropdownRender={() => (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
...dropdownStyles.dropdown,
|
|
||||||
padding: '8px 0',
|
|
||||||
maxHeight: '500px',
|
|
||||||
width: '400px',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header with search and controls */}
|
|
||||||
<div style={{ padding: '8px 12px', flexShrink: 0 }}>
|
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
|
||||||
{/* Search input */}
|
|
||||||
<Input
|
|
||||||
placeholder={searchPlaceholder}
|
|
||||||
value={searchText}
|
|
||||||
onChange={e => setSearchText(e.target.value)}
|
|
||||||
prefix={
|
|
||||||
<SearchOutlined
|
|
||||||
style={{
|
|
||||||
color: getThemeAwareColor(
|
|
||||||
token.colorTextTertiary,
|
|
||||||
token.colorTextQuaternary
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
suffix={
|
|
||||||
searchText && (
|
|
||||||
<Tooltip title={clearTooltip}>
|
|
||||||
<ClearOutlined
|
|
||||||
onClick={clearSearch}
|
|
||||||
style={{
|
|
||||||
cursor: 'pointer',
|
|
||||||
color: getThemeAwareColor(
|
|
||||||
token.colorTextTertiary,
|
|
||||||
token.colorTextQuaternary
|
|
||||||
),
|
|
||||||
transition: 'color 0.2s ease',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => {
|
|
||||||
e.currentTarget.style.color = getThemeAwareColor(
|
|
||||||
token.colorTextSecondary,
|
|
||||||
token.colorTextTertiary
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
onMouseLeave={e => {
|
|
||||||
e.currentTarget.style.color = getThemeAwareColor(
|
|
||||||
token.colorTextTertiary,
|
|
||||||
token.colorTextQuaternary
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Controls row */}
|
|
||||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
|
||||||
<Space size="small">
|
|
||||||
<Select
|
|
||||||
value={groupBy}
|
|
||||||
onChange={setGroupBy}
|
|
||||||
size="small"
|
|
||||||
style={{ width: '120px' }}
|
|
||||||
options={groupByOptions}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{groupBy !== 'none' && (
|
|
||||||
<Space size="small">
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
onClick={() => toggleAllGroups(true)}
|
|
||||||
style={{
|
|
||||||
color: getThemeAwareColor(
|
|
||||||
token.colorTextSecondary,
|
|
||||||
token.colorTextTertiary
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{expandAllText}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
onClick={() => toggleAllGroups(false)}
|
|
||||||
style={{
|
|
||||||
color: getThemeAwareColor(
|
|
||||||
token.colorTextSecondary,
|
|
||||||
token.colorTextTertiary
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{collapseAllText}
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
<Tooltip title={showSelectedTooltip}>
|
|
||||||
<Button
|
|
||||||
type={showSelectedOnly ? 'primary' : 'text'}
|
|
||||||
size="small"
|
|
||||||
icon={<FilterOutlined />}
|
|
||||||
onClick={() => setShowSelectedOnly(!showSelectedOnly)}
|
|
||||||
style={
|
|
||||||
!showSelectedOnly
|
|
||||||
? {
|
|
||||||
color: getThemeAwareColor(
|
|
||||||
token.colorTextSecondary,
|
|
||||||
token.colorTextTertiary
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: {}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</Space>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Select All checkbox */}
|
|
||||||
<div style={{ padding: '8px 12px', flexShrink: 0 }}>
|
|
||||||
<Checkbox
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
onChange={handleSelectAllChange}
|
|
||||||
checked={allSelected}
|
|
||||||
indeterminate={indeterminate}
|
|
||||||
>
|
|
||||||
<Space>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: getThemeAwareColor(token.colorText, token.colorTextBase),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectAllText}
|
|
||||||
</Text>
|
|
||||||
{selectedCount > 0 && (
|
|
||||||
<Badge
|
|
||||||
count={selectedCount}
|
|
||||||
size="small"
|
|
||||||
style={{
|
|
||||||
backgroundColor: getThemeAwareColor(
|
|
||||||
token.colorSuccess,
|
|
||||||
token.colorSuccessActive
|
|
||||||
),
|
|
||||||
color: getThemeAwareColor('#fff', token.colorTextLightSolid),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
</Checkbox>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Divider style={{ margin: '8px 0', flexShrink: 0 }} />
|
|
||||||
|
|
||||||
{/* Projects list */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
overflowY: 'auto',
|
|
||||||
flex: 1,
|
|
||||||
padding: '0 12px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{filteredProjects.length === 0 ? (
|
|
||||||
<Empty
|
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
||||||
description={
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: getThemeAwareColor(
|
|
||||||
token.colorTextTertiary,
|
|
||||||
token.colorTextQuaternary
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{searchText ? noProjectsText : noDataText}
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
style={{ margin: '20px 0' }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
groupedProjects.map(renderProjectGroup)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer with selection summary */}
|
|
||||||
{selectedCount > 0 && (
|
|
||||||
<>
|
|
||||||
<Divider style={{ margin: '8px 0', flexShrink: 0 }} />
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '8px 12px',
|
|
||||||
flexShrink: 0,
|
|
||||||
backgroundColor: getThemeAwareColor(
|
|
||||||
token.colorFillAlter,
|
|
||||||
token.colorFillQuaternary
|
|
||||||
),
|
|
||||||
borderRadius: `0 0 ${token.borderRadius}px ${token.borderRadius}px`,
|
|
||||||
borderTop: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
type="secondary"
|
|
||||||
style={{
|
|
||||||
fontSize: '12px',
|
|
||||||
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedCount} {projectsSelectedText}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
onOpenChange={visible => {
|
|
||||||
setDropdownVisible(visible);
|
|
||||||
if (!visible) {
|
|
||||||
setSearchText('');
|
|
||||||
setShowSelectedOnly(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Badge count={selectedCount} size="small" offset={[-5, 5]}>
|
|
||||||
<Button loading={loadingProjects}>
|
|
||||||
<Space>
|
|
||||||
{t('projects')}
|
|
||||||
<CaretDownFilled />
|
|
||||||
</Space>
|
|
||||||
</Button>
|
|
||||||
</Badge>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Projects;
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
import { CaretDownFilled } from '@/shared/antd-imports';
|
|
||||||
import { Button, Checkbox, Divider, Dropdown, Input, theme } from '@/shared/antd-imports';
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import type { CheckboxChangeEvent } from 'antd/es/checkbox';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
|
||||||
import {
|
|
||||||
fetchReportingCategories,
|
|
||||||
fetchReportingProjects,
|
|
||||||
setSelectOrDeselectAllTeams,
|
|
||||||
setSelectOrDeselectTeam,
|
|
||||||
} from '@/features/reporting/time-reports/time-reports-overview.slice';
|
|
||||||
|
|
||||||
const Team: React.FC = () => {
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const [searchText, setSearchText] = useState('');
|
|
||||||
const [selectAll, setSelectAll] = useState(true);
|
|
||||||
const { t } = useTranslation('time-report');
|
|
||||||
const [dropdownVisible, setDropdownVisible] = useState(false);
|
|
||||||
const { token } = theme.useToken();
|
|
||||||
|
|
||||||
const { teams, loadingTeams } = useAppSelector(state => state.timeReportsOverviewReducer);
|
|
||||||
|
|
||||||
const filteredItems = teams.filter(item =>
|
|
||||||
item.name?.toLowerCase().includes(searchText.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCheckboxChange = async (key: string, checked: boolean) => {
|
|
||||||
dispatch(setSelectOrDeselectTeam({ id: key, selected: checked }));
|
|
||||||
await dispatch(fetchReportingCategories());
|
|
||||||
await dispatch(fetchReportingProjects());
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectAllChange = async (e: CheckboxChangeEvent) => {
|
|
||||||
const isChecked = e.target.checked;
|
|
||||||
setSelectAll(isChecked);
|
|
||||||
dispatch(setSelectOrDeselectAllTeams(isChecked));
|
|
||||||
await dispatch(fetchReportingCategories());
|
|
||||||
await dispatch(fetchReportingProjects());
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Dropdown
|
|
||||||
menu={undefined}
|
|
||||||
placement="bottomLeft"
|
|
||||||
trigger={['click']}
|
|
||||||
dropdownRender={() => (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: token.colorBgContainer,
|
|
||||||
borderRadius: token.borderRadius,
|
|
||||||
boxShadow: token.boxShadow,
|
|
||||||
padding: '4px 0',
|
|
||||||
maxHeight: '330px',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ padding: '8px', flexShrink: 0 }}>
|
|
||||||
<Input
|
|
||||||
placeholder={t('searchByName')}
|
|
||||||
value={searchText}
|
|
||||||
onChange={e => setSearchText(e.target.value)}
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ padding: '0 12px', flexShrink: 0 }}>
|
|
||||||
<Checkbox
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
onChange={handleSelectAllChange}
|
|
||||||
checked={selectAll}
|
|
||||||
>
|
|
||||||
{t('selectAll')}
|
|
||||||
</Checkbox>
|
|
||||||
</div>
|
|
||||||
<Divider style={{ margin: '4px 0', flexShrink: 0 }} />
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
overflowY: 'auto',
|
|
||||||
flex: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{filteredItems.map(item => (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
style={{
|
|
||||||
padding: '8px 12px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
checked={item.selected}
|
|
||||||
onChange={e => handleCheckboxChange(item.id || '', e.target.checked)}
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</Checkbox>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
onOpenChange={visible => {
|
|
||||||
setDropdownVisible(visible);
|
|
||||||
if (!visible) {
|
|
||||||
setSearchText('');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button loading={loadingTeams}>
|
|
||||||
{t('teams')} <CaretDownFilled />
|
|
||||||
</Button>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Team;
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import React, { useEffect } from 'react';
|
|
||||||
import Team from './team';
|
|
||||||
import Categories from './categories';
|
|
||||||
import Projects from './projects';
|
|
||||||
import Billable from './billable';
|
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
|
||||||
import {
|
|
||||||
fetchReportingTeams,
|
|
||||||
fetchReportingProjects,
|
|
||||||
fetchReportingCategories,
|
|
||||||
} from '@/features/reporting/time-reports/time-reports-overview.slice';
|
|
||||||
|
|
||||||
const TimeReportPageHeader: React.FC = () => {
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchData = async () => {
|
|
||||||
await dispatch(fetchReportingTeams());
|
|
||||||
await dispatch(fetchReportingCategories());
|
|
||||||
await dispatch(fetchReportingProjects());
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchData();
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
|
||||||
<Team />
|
|
||||||
<Categories />
|
|
||||||
<Projects />
|
|
||||||
<Billable />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TimeReportPageHeader;
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Card, Flex } from '@/shared/antd-imports';
|
import { Card, Flex } from '@/shared/antd-imports';
|
||||||
import TimeReportPageHeader from '@/pages/reporting/timeReports/page-header/time-report-page-header';
|
import TimeReportPageHeader from '@/components/reporting/time-reports/page-header/TimeReportPageHeader';
|
||||||
import ProjectTimeSheetChart, {
|
import ProjectTimeSheetChart, {
|
||||||
ProjectTimeSheetChartRef,
|
ProjectTimeSheetChartRef,
|
||||||
} from '@/pages/reporting/time-reports/project-time-sheet/project-time-sheet-chart';
|
} from '@/pages/reporting/time-reports/project-time-sheet/project-time-sheet-chart';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||||
import TimeReportingRightHeader from './timeReportingRightHeader/TimeReportingRightHeader';
|
import TimeReportingRightHeader from '@/components/reporting/time-reports/right-header/TimeReportingRightHeader';
|
||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
|
|
||||||
const ProjectsTimeReports = () => {
|
const ProjectsTimeReports = () => {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ const LanguageAndRegionSettings = () => {
|
|||||||
label: 'Deutsch',
|
label: 'Deutsch',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: Language.ZH_CN,
|
value: Language.ZH,
|
||||||
label: '简体中文',
|
label: '简体中文',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -263,6 +263,7 @@ export type {
|
|||||||
PaginationProps,
|
PaginationProps,
|
||||||
CollapseProps,
|
CollapseProps,
|
||||||
TablePaginationConfig,
|
TablePaginationConfig,
|
||||||
|
CheckboxChangeEvent
|
||||||
} from 'antd/es';
|
} from 'antd/es';
|
||||||
|
|
||||||
// Dayjs
|
// Dayjs
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const currentDateString = (): string => {
|
|||||||
case 'de':
|
case 'de':
|
||||||
locale = 'de';
|
locale = 'de';
|
||||||
break;
|
break;
|
||||||
case 'zh_cn':
|
case 'zh':
|
||||||
locale = 'zh-cn';
|
locale = 'zh-cn';
|
||||||
break;
|
break;
|
||||||
case 'alb':
|
case 'alb':
|
||||||
@@ -45,7 +45,7 @@ export const currentDateString = (): string => {
|
|||||||
case 'de':
|
case 'de':
|
||||||
todayText = 'Heute ist';
|
todayText = 'Heute ist';
|
||||||
break;
|
break;
|
||||||
case 'zh_cn':
|
case 'zh':
|
||||||
todayText = '今天是';
|
todayText = '今天是';
|
||||||
break;
|
break;
|
||||||
case 'alb':
|
case 'alb':
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export const greetingString = (name: string): string => {
|
|||||||
morning = 'Morgen';
|
morning = 'Morgen';
|
||||||
afternoon = 'Tag';
|
afternoon = 'Tag';
|
||||||
evening = 'Abend';
|
evening = 'Abend';
|
||||||
} else if (language === 'zh_cn') {
|
} else if (language === 'zh') {
|
||||||
greetingPrefix = '你好';
|
greetingPrefix = '你好';
|
||||||
greetingSuffix = '';
|
greetingSuffix = '';
|
||||||
morning = '早上好';
|
morning = '早上好';
|
||||||
@@ -56,7 +56,7 @@ export const greetingString = (name: string): string => {
|
|||||||
else localizedTimePeriod = evening;
|
else localizedTimePeriod = evening;
|
||||||
|
|
||||||
// Handle Chinese language which has different structure
|
// Handle Chinese language which has different structure
|
||||||
if (language === 'zh_cn') {
|
if (language === 'zh') {
|
||||||
return `${greetingPrefix} ${name}, ${localizedTimePeriod}!`;
|
return `${greetingPrefix} ${name}, ${localizedTimePeriod}!`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user