diff --git a/package.json b/package.json index 3518d95..ffd57b6 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "install": "^0.13.0", "jotai": "^2.10.3", "lowlight": "^3.2.0", - "lucide-react": "^0.460.0", + "lucide-react": "^0.462.0", "nanoid": "^5.0.9", "next": "^14.2.18", "next-auth": "5.0.0-beta.25", @@ -92,9 +92,9 @@ "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-unused-imports": "^4.1.4", - "globals": "^15.12.0", + "globals": "^15.13.0", "postcss": "^8.4.49", - "prettier": "3.3.3", + "prettier": "3.4.1", "qs": "^6.13.1", "tailwindcss": "^3.4.15", "typescript": "^5.7.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 762f5a5..c97cfa7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -152,8 +152,8 @@ importers: specifier: ^3.2.0 version: 3.2.0 lucide-react: - specifier: ^0.460.0 - version: 0.460.0(react@18.3.1) + specifier: ^0.462.0 + version: 0.462.0(react@18.3.1) nanoid: specifier: ^5.0.9 version: 5.0.9 @@ -252,14 +252,14 @@ importers: specifier: ^4.1.4 version: 4.1.4(@typescript-eslint/eslint-plugin@8.16.0(@typescript-eslint/parser@8.16.0(eslint@9.16.0(jiti@1.21.6))(typescript@5.7.2))(eslint@9.16.0(jiti@1.21.6))(typescript@5.7.2))(eslint@9.16.0(jiti@1.21.6)) globals: - specifier: ^15.12.0 - version: 15.12.0 + specifier: ^15.13.0 + version: 15.13.0 postcss: specifier: ^8.4.49 version: 8.4.49 prettier: - specifier: 3.3.3 - version: 3.3.3 + specifier: 3.4.1 + version: 3.4.1 qs: specifier: ^6.13.1 version: 6.13.1 @@ -2268,10 +2268,10 @@ packages: } engines: { node: ">= 6" } - caniuse-lite@1.0.30001684: + caniuse-lite@1.0.30001685: resolution: { - integrity: sha512-G1LRwLIQjBQoyq0ZJGqGIJUXzJ8irpbjHLpVRXDvBEScFJ9b17sgK6vlx0GAJFE21okD7zXl08rRRUfq6HdoEQ==, + integrity: sha512-e/kJN1EMyHQzgcMEEgoo+YTCO1NGCmIYHk5Qk8jT6AazWemS5QFKJ5ShCJlH3GZrNIdZofcNCEwZqbMjjKzmnA==, } chalk@4.1.2: @@ -3202,10 +3202,10 @@ packages: } engines: { node: ">=18" } - globals@15.12.0: + globals@15.13.0: resolution: { - integrity: sha512-1+gLErljJFhbOVyaetcwJiJ4+eLe45S2E7P5UiZ9xGfeq3ATQf5DOv9G7MH3gGbKQLkzmNh2DxfZwLdw+j6oTQ==, + integrity: sha512-49TewVEz0UxZjr1WYYsWpPrhyC/B/pA8Bq0fUmet2n+eR7yn0IvNzNaoBwnK6mdkzcN+se7Ez9zUgULTz2QH4g==, } engines: { node: ">=18" } @@ -3254,10 +3254,10 @@ packages: integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==, } - has-proto@1.0.3: + has-proto@1.1.0: resolution: { - integrity: sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==, + integrity: sha512-QLdzI9IIO1Jg7f9GT1gXpPpXArAn6cS31R1eEZqz08Gc+uQ8/XiqHWt17Fiw+2p6oTTIq5GXEpQkAlA88YRl/Q==, } engines: { node: ">= 0.4" } @@ -3390,10 +3390,10 @@ packages: } engines: { node: ">=8" } - is-boolean-object@1.1.2: + is-boolean-object@1.2.0: resolution: { - integrity: sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==, + integrity: sha512-kR5g0+dXf/+kXnqI+lu0URKYPKgICtHGGNCDSB10AaUFj3o/HkB3u7WfpRBJGFopxxY0oH3ux7ZsDjLtK7xqvw==, } engines: { node: ">= 0.4" } @@ -3503,10 +3503,10 @@ packages: } engines: { node: ">= 0.4" } - is-number-object@1.0.7: + is-number-object@1.1.0: resolution: { - integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==, + integrity: sha512-KVSZV0Dunv9DTPkhXwcZ3Q+tUc9TsaE1ZwX5J2WMvsSGS6Md8TFPun5uwh0yRdrNerI6vf/tbJxqSx4c1ZI1Lw==, } engines: { node: ">= 0.4" } @@ -3545,10 +3545,10 @@ packages: } engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } - is-string@1.0.7: + is-string@1.1.0: resolution: { - integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==, + integrity: sha512-PlfzajuF9vSo5wErv3MJAKD/nqf9ngAs1NFQYm16nUYFO2IzxJ2hcm+IOCg+EEopdykNNUhVq5cz35cAUxU8+g==, } engines: { node: ">= 0.4" } @@ -3841,10 +3841,10 @@ packages: integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==, } - lucide-react@0.460.0: + lucide-react@0.462.0: resolution: { - integrity: sha512-BVtq/DykVeIvRTJvRAgCsOwaGL8Un3Bxh8MbDxMhEWlZay3T4IpEKDEpwt5KZ0KJMHzgm6jrltxlT5eXOWXDHg==, + integrity: sha512-NTL7EbAao9IFtuSivSZgrAh4fZd09Lr+6MTkqIxuHaH2nnYiYIzXPo06cOxHg9wKLdj6LL8TByG4qpePqwgx/g==, } peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc @@ -4373,10 +4373,10 @@ packages: } engines: { node: ">= 0.8.0" } - prettier@3.3.3: + prettier@3.4.1: resolution: { - integrity: sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==, + integrity: sha512-G+YdqtITVZmOJje6QkXQWzl3fSfMxFwm1tjTyo9exhkmWSqC4Yhd1+lug++IlR2mvRVAxEDDWYkQdeSztajqgg==, } engines: { node: ">=14" } hasBin: true @@ -6682,7 +6682,7 @@ snapshots: es-abstract: 1.23.5 es-object-atoms: 1.0.0 get-intrinsic: 1.2.4 - is-string: 1.0.7 + is-string: 1.1.0 array.prototype.findlast@1.2.5: dependencies: @@ -6796,7 +6796,7 @@ snapshots: camelcase-css@2.0.1: {} - caniuse-lite@1.0.30001684: {} + caniuse-lite@1.0.30001685: {} chalk@4.1.2: dependencies: @@ -7046,7 +7046,7 @@ snapshots: globalthis: 1.0.4 gopd: 1.1.0 has-property-descriptors: 1.0.2 - has-proto: 1.0.3 + has-proto: 1.1.0 has-symbols: 1.0.3 hasown: 2.0.2 internal-slot: 1.0.7 @@ -7056,7 +7056,7 @@ snapshots: is-negative-zero: 2.0.3 is-regex: 1.2.0 is-shared-array-buffer: 1.0.3 - is-string: 1.0.7 + is-string: 1.1.0 is-typed-array: 1.1.13 is-weakref: 1.0.2 object-inspect: 1.13.3 @@ -7093,7 +7093,7 @@ snapshots: globalthis: 1.0.4 gopd: 1.1.0 has-property-descriptors: 1.0.2 - has-proto: 1.0.3 + has-proto: 1.1.0 has-symbols: 1.0.3 internal-slot: 1.0.7 iterator.prototype: 1.1.3 @@ -7435,7 +7435,7 @@ snapshots: dependencies: es-errors: 1.3.0 function-bind: 1.1.2 - has-proto: 1.0.3 + has-proto: 1.1.0 has-symbols: 1.0.3 hasown: 2.0.2 @@ -7480,7 +7480,7 @@ snapshots: globals@14.0.0: {} - globals@15.12.0: {} + globals@15.13.0: {} globalthis@1.0.4: dependencies: @@ -7503,7 +7503,9 @@ snapshots: dependencies: es-define-property: 1.0.0 - has-proto@1.0.3: {} + has-proto@1.1.0: + dependencies: + call-bind: 1.0.7 has-symbols@1.0.3: {} @@ -7572,7 +7574,7 @@ snapshots: dependencies: binary-extensions: 2.3.0 - is-boolean-object@1.1.2: + is-boolean-object@1.2.0: dependencies: call-bind: 1.0.7 has-tostringtag: 1.0.2 @@ -7623,8 +7625,9 @@ snapshots: is-negative-zero@2.0.3: {} - is-number-object@1.0.7: + is-number-object@1.1.0: dependencies: + call-bind: 1.0.7 has-tostringtag: 1.0.2 is-number@7.0.0: {} @@ -7644,8 +7647,9 @@ snapshots: is-stream@3.0.0: {} - is-string@1.0.7: + is-string@1.1.0: dependencies: + call-bind: 1.0.7 has-tostringtag: 1.0.2 is-symbol@1.0.4: @@ -7793,7 +7797,7 @@ snapshots: lru-cache@10.4.3: {} - lucide-react@0.460.0(react@18.3.1): + lucide-react@0.462.0(react@18.3.1): dependencies: react: 18.3.1 @@ -7867,7 +7871,7 @@ snapshots: "@next/env": 14.2.18 "@swc/helpers": 0.5.5 busboy: 1.6.0 - caniuse-lite: 1.0.30001684 + caniuse-lite: 1.0.30001685 graceful-fs: 4.2.11 postcss: 8.4.31 react: 18.3.1 @@ -8082,7 +8086,7 @@ snapshots: prelude-ls@1.2.1: {} - prettier@3.3.3: {} + prettier@3.4.1: {} pretty-format@3.8.0: {} @@ -8625,7 +8629,7 @@ snapshots: call-bind: 1.0.7 for-each: 0.3.3 gopd: 1.1.0 - has-proto: 1.0.3 + has-proto: 1.1.0 is-typed-array: 1.1.13 typed-array-byte-offset@1.0.3: @@ -8634,7 +8638,7 @@ snapshots: call-bind: 1.0.7 for-each: 0.3.3 gopd: 1.1.0 - has-proto: 1.0.3 + has-proto: 1.1.0 is-typed-array: 1.1.13 reflect.getprototypeof: 1.0.7 @@ -8727,9 +8731,9 @@ snapshots: which-boxed-primitive@1.0.2: dependencies: is-bigint: 1.0.4 - is-boolean-object: 1.1.2 - is-number-object: 1.0.7 - is-string: 1.0.7 + is-boolean-object: 1.2.0 + is-number-object: 1.1.0 + is-string: 1.1.0 is-symbol: 1.0.4 which-builtin-type@1.2.0: diff --git a/src/components/authorities/authority-view.tsx b/src/components/authorities/authority-view.tsx index 8be02f3..c21020f 100644 --- a/src/components/authorities/authority-view.tsx +++ b/src/components/authorities/authority-view.tsx @@ -7,6 +7,7 @@ import React, { useEffect, useState } from "react"; import AddUserToAuthorityDialog from "@/components/authorities/authority-add-user-dialog"; import { Heading } from "@/components/heading"; +import PaginationExt from "@/components/shared/pagination-ext"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; @@ -44,6 +45,9 @@ export const AuthorityView: React.FC> = ({ const permissionLevel = usePagePermission(); const [open, setOpen] = useState(false); const [users, setUsers] = useState>(); + const [currentPage, setCurrentPage] = useState(1); + const [totalPages, setTotalPages] = useState(0); + const [totalElements, setTotalElements] = useState(0); const [authority, setAuthority] = useState(entity); const [resourcePermissions, setResourcePermissions] = useState>(); @@ -51,13 +55,19 @@ export const AuthorityView: React.FC> = ({ const router = useRouter(); async function fetchUsers() { - const userData = await getUsersByAuthority(authority.name); - setUsers(userData); + getUsersByAuthority(authority.name, { + page: currentPage, + size: 10, + }).then((pageableResult) => { + setUsers(pageableResult.content); + setTotalElements(pageableResult.totalElements); + setTotalPages(pageableResult.totalPages); + }); } useEffect(() => { fetchUsers(); - }, []); + }, [entity, currentPage]); useEffect(() => { async function fetchResourcePermissions() { @@ -78,7 +88,7 @@ export const AuthorityView: React.FC> = ({
{PermissionUtils.canWrite(permissionLevel) && ( @@ -105,67 +115,77 @@ export const AuthorityView: React.FC> = ({ )}
-
- {users?.map((user: UserType) => ( -
-
- - - - - - -
-
-
- +
+
+ {users?.map((user: UserType) => ( +
+
+ + + + + +
- Email: {user.email} +
+ +
+
+ Email:{" "} + {user.email} +
+
Title: {user.title}
-
Title: {user.title}
+ {PermissionUtils.canWrite(permissionLevel) && ( + + + + + + + + + removeUserOutAuthority(user)} + > + Remove user + + + +

+ This action will revoke the selected user’s access + and permissions associated with this authority +

+
+
+
+
+
+ )}
- {PermissionUtils.canWrite(permissionLevel) && ( - - - - - - - - - removeUserOutAuthority(user)} - > - Remove user - - - -

- This action will revoke the selected user’s access - and permissions associated with this authority -

-
-
-
-
-
- )} -
- ))} + ))} + setCurrentPage(page)} + /> +
Resource Permissions diff --git a/src/components/shared/loading-place-holder.tsx b/src/components/shared/loading-place-holder.tsx new file mode 100644 index 0000000..3237422 --- /dev/null +++ b/src/components/shared/loading-place-holder.tsx @@ -0,0 +1,33 @@ +"use client"; + +import React from "react"; + +import { Skeleton } from "@/components/ui/skeleton"; // Assuming you have a skeleton component +import { Spinner } from "@/components/ui/spinner"; // Assuming you have a spinner component + +interface LoadingPlaceholderProps { + message?: string; // Optional message to display + skeletonCount?: number; // Number of skeleton lines to render + skeletonWidth?: string; // Width of the skeleton lines +} + +const LoadingPlaceholder: React.FC = ({ + message = "Loading...", + skeletonCount = 3, + skeletonWidth = "20rem", +}) => { + return ( +
+ + {message} + +
+ {Array.from({ length: skeletonCount }).map((_, index) => ( + + ))} +
+
+ ); +}; + +export default LoadingPlaceholder; diff --git a/src/components/teams/team-dashboard-kpis.tsx b/src/components/teams/team-dashboard-kpis.tsx index dcb45a3..9c49bf5 100644 --- a/src/components/teams/team-dashboard-kpis.tsx +++ b/src/components/teams/team-dashboard-kpis.tsx @@ -28,7 +28,7 @@ const TeamDashboardTopSection = ({ teamId }: { teamId: number }) => { const [overDueTickets, setOverdueTickets] = useState(0); useEffect(() => { - function fetchStatisticData() { + async function fetchStatisticData() { getTicketStatisticsByTeamId(teamId).then((data) => { setTotalTickets(data.totalTickets); setPendingTickets(data.pendingTickets); diff --git a/src/components/teams/team-dashboard-recent-activity.tsx b/src/components/teams/team-dashboard-recent-activity.tsx index 0d63259..edc0b2a 100644 --- a/src/components/teams/team-dashboard-recent-activity.tsx +++ b/src/components/teams/team-dashboard-recent-activity.tsx @@ -4,6 +4,7 @@ import React, { useEffect, useState } from "react"; import PaginationExt from "@/components/shared/pagination-ext"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Spinner } from "@/components/ui/spinner"; import { getActivityLogs } from "@/lib/actions/activity-logs.action"; import { formatDateTimeDistanceToNow } from "@/lib/datetime"; import { ActivityLogDTO } from "@/types/activity-logs"; @@ -17,13 +18,17 @@ const RecentTeamActivities = ({ team }: DashboardTrendsAndActivityProps) => { const [activityLogs, setActivityLogs] = useState([]); const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(0); + const [loading, setLoading] = useState(false); // Loading state useEffect(() => { async function fetchActivityLogs() { - getActivityLogs("Team", team.id!, currentPage, 5).then((data) => { - setActivityLogs(data.content); - setTotalPages(data.totalPages); - }); + setLoading(true); + getActivityLogs("Team", team.id!, currentPage, 5) + .then((data) => { + setActivityLogs(data.content); + setTotalPages(data.totalPages); + }) + .finally(() => setLoading(false)); } fetchActivityLogs(); }, [team, currentPage]); @@ -34,7 +39,13 @@ const RecentTeamActivities = ({ team }: DashboardTrendsAndActivityProps) => { Recent Activity - {activityLogs && activityLogs.length > 0 ? ( + {loading ? ( +
+ + Loading data ... + +
+ ) : activityLogs && activityLogs.length > 0 ? (
{activityLogs.map((activityLog, index) => (
) => { const permissionLevel = usePagePermission(); - return (
diff --git a/src/components/teams/team-form.tsx b/src/components/teams/team-form.tsx index fe81b74..932ed9c 100644 --- a/src/components/teams/team-form.tsx +++ b/src/components/teams/team-form.tsx @@ -27,6 +27,7 @@ import { } from "@/components/ui/tooltip"; import { useImageCropper } from "@/hooks/use-image-cropper"; import { apiClient } from "@/lib/api-client"; +import { obfuscate } from "@/lib/endecode"; import { validateForm } from "@/lib/validator"; import { TeamDTO, TeamDTOSchema } from "@/types/teams"; @@ -60,15 +61,17 @@ export const TeamForm = ({ initialData }: FormProps) => { formData.append("file", selectedFile); } + let savedTeam: TeamDTO; + if (team.id) { - await apiClient( + savedTeam = await apiClient( "/api/teams", "PUT", formData, session?.user?.accessToken, ); } else { - await apiClient( + savedTeam = await apiClient( "/api/teams", "POST", formData, @@ -76,7 +79,7 @@ export const TeamForm = ({ initialData }: FormProps) => { ); } - router.push("/portal/teams"); + router.push(`/portal/teams/${obfuscate(savedTeam.id)}/dashboard`); } } diff --git a/src/components/teams/team-requests-creation-timeseries-chart.tsx b/src/components/teams/team-requests-creation-timeseries-chart.tsx index f73e21e..7ffebe4 100644 --- a/src/components/teams/team-requests-creation-timeseries-chart.tsx +++ b/src/components/teams/team-requests-creation-timeseries-chart.tsx @@ -13,6 +13,7 @@ import { } from "recharts"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Spinner } from "@/components/ui/spinner"; import { getTicketCreationDaySeries } from "@/lib/actions/teams-request.action"; import { TicketActionCountByDateDTO } from "@/types/teams"; @@ -26,17 +27,20 @@ const TicketCreationByDaySeriesChart = ({ const [data, setData] = useState< (TicketActionCountByDateDTO & { displayDay: string })[] >([]); + const [loading, setLoading] = useState(true); // Loading state useEffect(() => { async function fetchData() { - const response = await getTicketCreationDaySeries(teamId, days); - - const formattedData = response.map((item, index) => ({ - ...item, - displayDay: `Day ${index + 1}`, // Use "Day 1", "Day 2", etc., as the X-Axis label - })); - - setData(formattedData); + setLoading(true); + getTicketCreationDaySeries(teamId, days) + .then((data) => { + const formattedData = data.map((item, index) => ({ + ...item, + displayDay: `Day ${index + 1}`, // Use "Day 1", "Day 2", etc., as the X-Axis label + })); + setData(formattedData); + }) + .finally(() => setLoading(false)); } fetchData(); }, [teamId, days]); @@ -46,44 +50,55 @@ const TicketCreationByDaySeriesChart = ({ Daily Ticket Trends (Created & Closed) - - - - - - - [ - `${value}`, - name === "createdCount" ? "Created Tickets" : "Closed Tickets", - ]} - labelFormatter={(label: string) => { - const date = data.find((d) => d.displayDay === label)?.date; - return {date || "Unknown Date"}; - }} - /> - - - - - + + {loading ? ( +
+ + Loading chart data... + +
+ ) : ( + // Render the chart if not loading + + + + + + [ + `${value}`, + name === "createdCount" + ? "Created Tickets" + : "Closed Tickets", + ]} + labelFormatter={(label: string) => { + const date = data.find((d) => d.displayDay === label)?.date; + return {date || "Unknown Date"}; + }} + /> + + + + + + )}
); diff --git a/src/components/teams/team-requests-distribution-chart.tsx b/src/components/teams/team-requests-distribution-chart.tsx index 33421e1..3b21777 100644 --- a/src/components/teams/team-requests-distribution-chart.tsx +++ b/src/components/teams/team-requests-distribution-chart.tsx @@ -14,12 +14,13 @@ import { } from "recharts"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Spinner } from "@/components/ui/spinner"; // Import your spinner component import { getTicketsAssignmentDistribution } from "@/lib/actions/teams-request.action"; import { obfuscate } from "@/lib/endecode"; import { TicketDistributionDTO } from "@/types/team-requests"; interface TicketDistributionChartProps { - teamId: number; // The ID of the team to fetch data for + teamId: number; } // Colors for the bar chart @@ -30,23 +31,14 @@ const TicketDistributionChart: React.FC = ({ }) => { const [data, setData] = useState([]); const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); // Fetch data from the API useEffect(() => { const fetchData = async () => { setLoading(true); - setError(null); - - try { - const response = await getTicketsAssignmentDistribution(teamId); - setData(response); - } catch (err) { - setError("Failed to load ticket distribution data."); - console.error(err); - } finally { - setLoading(false); - } + getTicketsAssignmentDistribution(teamId) + .then((data) => setData(data)) + .finally(() => setLoading(false)); }; fetchData(); @@ -104,17 +96,14 @@ const TicketDistributionChart: React.FC = ({ Ticket Distribution - {loading && ( -

Loading ticket distribution...

- )} - - {error &&

{error}

} - - {!loading && !error && chartData.length === 0 && ( + {loading ? ( +
+ + Loading chart data... +
+ ) : chartData.length === 0 ? (

No ticket distribution data available.

- )} - - {!loading && !error && chartData.length > 0 && ( + ) : (
{ const [totalPages, setTotalPages] = useState(0); const [totalTickets, setTotalTickets] = useState(0); const [tickets, setTickets] = useState([]); + const [loading, setLoading] = useState(false); const [sortBy, setSortBy] = useState("priority"); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); useEffect(() => { const fetchData = async () => { - const data = await getOverdueTickets( - teamId, - currentPage, - sortBy, - sortDirection, - ); - setTickets(data.content); - setTotalPages(data.totalPages); - setTotalTickets(data.totalElements); + setLoading(true); + getOverdueTickets(teamId, currentPage, sortBy, sortDirection) + .then((data) => { + setTickets(data.content); + setTotalPages(data.totalPages); + setTotalTickets(data.totalElements); + }) + .finally(() => setLoading(false)); }; fetchData(); @@ -79,56 +80,65 @@ const OverdueTickets = ({ teamId }: { teamId: number }) => {
-
- {tickets.length > 0 ? ( - tickets.map((ticket, index) => ( -
-
- -
- + {loading ? ( +
+ + Loading data ... + +
+ ) : ( +
+ {tickets.length > 0 ? ( + tickets.map((ticket, index) => ( +
+
+ +
+ +
+ +

+ Modified at:{" "} + {formatDateTimeDistanceToNow(ticket.modifiedAt)} +

- -

- Modified at: {formatDateTimeDistanceToNow(ticket.modifiedAt)} -

-
- )) - ) : ( -

- No overdue tickets available -

- )} -
+ )) + ) : ( +

+ No overdue tickets available +

+ )} +
+ )} { [], ); const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); useEffect(() => { // Fetch priority distribution data const fetchPriorityData = async () => { setLoading(true); - setError(null); - try { - const data = await getTicketsPriorityDistribution(teamId); - setPriorityData(data); - } catch (err) { - setError("Failed to load priority distribution data."); - console.error(err); - } finally { - setLoading(false); - } + getTicketsPriorityDistribution(teamId) + .then((data) => setPriorityData(data)) + .finally(() => setLoading(false)); }; fetchPriorityData(); @@ -56,15 +49,15 @@ const TicketPriorityPieChart = ({ teamId }: { teamId: number }) => { Priority Distribution - {loading &&

Loading...

} - - {error &&

{error}

} - - {!loading && !error && priorityData.length === 0 && ( + {loading ? ( +
+ + Loading chart data... + +
+ ) : priorityData.length === 0 ? (

No data available.

- )} - - {!loading && !error && priorityData.length > 0 && ( + ) : (
diff --git a/src/components/teams/team-requests-unassigned.tsx b/src/components/teams/team-requests-unassigned.tsx index 993a181..4eb4931 100644 --- a/src/components/teams/team-requests-unassigned.tsx +++ b/src/components/teams/team-requests-unassigned.tsx @@ -9,6 +9,7 @@ import TruncatedHtmlLabel from "@/components/shared/truncate-html-label"; import { PriorityDisplay } from "@/components/teams/team-requests-priority-display"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Spinner } from "@/components/ui/spinner"; // Add your spinner component import { Tooltip, TooltipContent, @@ -24,21 +25,21 @@ const UnassignedTickets = ({ teamId }: { teamId: number }) => { const [totalPages, setTotalPages] = useState(0); const [totalTickets, setTotalTickets] = useState(0); const [tickets, setTickets] = useState([]); + const [loading, setLoading] = useState(false); const [sortBy, setSortBy] = useState("priority"); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); useEffect(() => { const fetchData = async () => { - const data = await getUnassignedTickets( - teamId, - currentPage, - sortBy, - sortDirection, - ); - setTickets(data.content); - setTotalPages(data.totalPages); - setTotalTickets(data.totalElements); + setLoading(true); + getUnassignedTickets(teamId, currentPage, sortBy, sortDirection) + .then((data) => { + setTickets(data.content); + setTotalPages(data.totalPages); + setTotalTickets(data.totalElements); + }) + .finally(() => setLoading(false)); }; fetchData(); @@ -79,56 +80,66 @@ const UnassignedTickets = ({ teamId }: { teamId: number }) => {
-
- {tickets.length > 0 ? ( - tickets.map((ticket, index) => ( -
-
- -
- + {loading ? ( + // Show a spinner while loading +
+ + Loading data ... + +
+ ) : ( +
+ {tickets.length > 0 ? ( + tickets.map((ticket, index) => ( +
+
+ +
+ +
+ +

+ Modified at:{" "} + {formatDateTimeDistanceToNow(ticket.modifiedAt)} +

- -

- Modified at: {formatDateTimeDistanceToNow(ticket.modifiedAt)} -

-
- )) - ) : ( -

- No unassigned tickets available -

- )} -
+ )) + ) : ( +

+ No unassigned tickets available +

+ )} +
+ )} ) => { useEffect(() => { const groups: GroupFilter[] = []; + let assignedGroupFilter: GroupFilter | undefined = undefined; // Status filters group const statusFilters: Filter[] = []; @@ -109,7 +110,7 @@ const TeamRequestsView = ({ entity: team }: ViewProps) => { }); } if (statuses.includes("Assigned")) { - groups.push({ + assignedGroupFilter = { logicalOperator: "AND", // Logical "AND" for the two conditions filters: [ { @@ -123,13 +124,14 @@ const TeamRequestsView = ({ entity: team }: ViewProps) => { value: false, }, ], - }); + }; } // If there are any status filters, add them as an OR group - if (statusFilters.length > 0) { + if (statusFilters.length > 0 || assignedGroupFilter) { groups.push({ filters: statusFilters, + groups: assignedGroupFilter ? [assignedGroupFilter] : [], logicalOperator: "OR", // Logical "OR" for statuses }); } diff --git a/src/components/teams/team-users.tsx b/src/components/teams/team-users.tsx index 4c36d5d..c55d310 100644 --- a/src/components/teams/team-users.tsx +++ b/src/components/teams/team-users.tsx @@ -6,6 +6,7 @@ import React, { useEffect, useState } from "react"; import { Heading } from "@/components/heading"; import { TeamAvatar } from "@/components/shared/avatar-display"; +import LoadingPlaceHolder from "@/components/shared/loading-place-holder"; // Import your spinner component import AddUserToTeamDialog from "@/components/teams/team-add-user-dialog"; import { AlertDialog, @@ -82,8 +83,6 @@ const TeamUsersView = ({ entity: team }: ViewProps) => { await fetchUsers(); } - if (loading) return
Loading...
; - // Group users by role const groupedUsers = items.reduce>( (groups, user) => { @@ -138,84 +137,89 @@ const TeamUsersView = ({ entity: team }: ViewProps) => { )}
- {roleOrder.map( - (role) => - groupedUsers[role] && ( -
-

{role}

-
- {groupedUsers[role].map((user) => ( -
-
- - - - - - -
-
-
- + {loading ? ( + + ) : ( + roleOrder.map( + (role) => + groupedUsers[role] && ( +
+

{role}

+
+ {groupedUsers[role].map((user) => ( +
+
+ + + + + +
- Email:{" "} - +
+ +
+
+ Email:{" "} + +
+
Timezone: {user.timezone}
+
Title: {user.title}
-
Timezone: {user.timezone}
-
Title: {user.title}
+ {(PermissionUtils.canWrite(permissionLevel) || + teamRole === "Manager") && ( + + + + + + + + + removeUserOutTeam(user)} + > + Remove user + + + +

+ This action will remove user{" "} + {user.firstName} {user.lastName} out of team{" "} + {team.name} +

+
+
+
+
+
+ )}
- {(PermissionUtils.canWrite(permissionLevel) || - teamRole === "Manager") && ( - - - - - - - - - removeUserOutTeam(user)} - > - Remove user - - - -

- This action will remove user {user.firstName}{" "} - {user.lastName} out of team {team.name} -

-
-
-
-
-
- )} -
- ))} + ))} +
-
- ), + ), + ) )} diff --git a/src/components/teams/team-workflows.tsx b/src/components/teams/team-workflows.tsx index 65fd151..7e629f5 100644 --- a/src/components/teams/team-workflows.tsx +++ b/src/components/teams/team-workflows.tsx @@ -42,7 +42,7 @@ const TeamWorkflowsView = ({ entity: team }: ViewProps) => {
- + , + VariantProps { + className?: string; + children?: React.ReactNode; +} + +export function Spinner({ + size, + show, + children, + className, +}: SpinnerContentProps) { + return ( + + + {children} + + ); +} diff --git a/src/components/users/users-list.tsx b/src/components/users/users-list.tsx index c0e9dc0..915ef2c 100644 --- a/src/components/users/users-list.tsx +++ b/src/components/users/users-list.tsx @@ -1,17 +1,23 @@ "use client"; import { formatDistanceToNow } from "date-fns"; -import { Plus } from "lucide-react"; +import { ArrowDownAZ, ArrowUpAZ, Plus } from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import React, { useCallback, useEffect, useState } from "react"; import { Heading } from "@/components/heading"; +import LoadingPlaceholder from "@/components/shared/loading-place-holder"; import PaginationExt from "@/components/shared/pagination-ext"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button, buttonVariants } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Separator } from "@/components/ui/separator"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import DefaultUserLogo from "@/components/users/user-logo"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import { usePagePermission } from "@/hooks/use-page-permission"; @@ -31,6 +37,8 @@ export const UserList = () => { const [userSearchTerm, setUserSearchTerm] = useState( undefined, ); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); + const permissionLevel = usePagePermission(); const searchParams = useSearchParams(); @@ -39,33 +47,39 @@ export const UserList = () => { const fetchData = useCallback(async () => { setLoading(true); - try { - const query: QueryDTO = { - filters: userSearchTerm - ? [ - { - field: "firstName,lastName", - operator: "lk", - value: userSearchTerm, - }, - ] - : [], - }; - // Fetch data using the QueryDTO - const pageResult = await searchUsers(query, { - page: currentPage, - size: 10, - }); - setItems(pageResult.content); - setTotalElements(pageResult.totalElements); - setTotalPages(pageResult.totalPages); - } finally { - setLoading(false); - } + const query: QueryDTO = { + filters: userSearchTerm + ? [ + { + field: "firstName,lastName", + operator: "lk", + value: userSearchTerm, + }, + ] + : [], + }; + + searchUsers(query, { + page: currentPage, + size: 10, + sort: [ + { + field: "firstName,lastName", + direction: sortDirection, + }, + ], + }) + .then((pageResult) => { + setItems(pageResult.content); + setTotalElements(pageResult.totalElements); + setTotalPages(pageResult.totalPages); + }) + .finally(() => setLoading(false)); }, [ userSearchTerm, currentPage, + sortDirection, setLoading, setItems, setTotalElements, @@ -87,7 +101,9 @@ export const UserList = () => { fetchData(); }, [fetchData]); - if (loading) return
Loading...
; + const toggleSortDirection = () => { + setSortDirection((prev) => (prev === "asc" ? "desc" : "asc")); + }; return (
@@ -106,6 +122,18 @@ export const UserList = () => { }} defaultValue={searchParams.get("name")?.toString()} /> + + + + + + {sortDirection === "asc" + ? "Sort names A → Z" + : "Sort names Z → A"} + + {PermissionUtils.canWrite(permissionLevel) && ( {
-
- {items?.map((user) => ( -
-
- - - - - - -
-
-
- -
+ {loading ? ( + + ) : ( +
+ {items?.map((user) => ( +
- Email:{" "} - + + + + + +
-
Title: {user.title}
-
Timezone: {user.timezone}
-
- Last login time:{" "} - {user.lastLoginTime - ? formatDistanceToNow(new Date(user.lastLoginTime), { - addSuffix: true, - }) - : "No recent login"} +
+
+ +
+
+ Email:{" "} + +
+
Title: {user.title}
+
Timezone: {user.timezone}
+
+ Last login time:{" "} + {user.lastLoginTime + ? formatDistanceToNow(new Date(user.lastLoginTime), { + addSuffix: true, + }) + : "No recent login"} +
-
- ))} -
+ ))} +
+ )} { @@ -39,9 +40,13 @@ export const batchSavePermissions = async ( >(`${BACKEND_API}/api/authority-permissions/batchSave`, permissions); }; -export async function getUsersByAuthority(authority: string) { - return get>( - `${BACKEND_API}/api/authorities/${authority}/users`, +export async function getUsersByAuthority( + authority: string, + pagination: Pagination, +) { + const queryParams = createQueryParams(pagination); + return get>( + `${BACKEND_API}/api/authorities/${authority}/users?${queryParams.toString()}`, ); } diff --git a/src/lib/actions/commons.action.ts b/src/lib/actions/commons.action.ts index 1d5d6dc..7085bfc 100644 --- a/src/lib/actions/commons.action.ts +++ b/src/lib/actions/commons.action.ts @@ -6,6 +6,7 @@ import { auth } from "@/auth"; import { handleError, HttpError } from "@/lib/errors"; import { PageableResult } from "@/types/commons"; import { + createQueryParams, Pagination, paginationSchema, QueryDTO, @@ -114,18 +115,7 @@ export const doAdvanceSearch = async ( ); } - // Build pagination URL parameters - const queryParams = new URLSearchParams({ - page: pagination.page.toString(), - size: pagination.size.toString(), - ...pagination.sort?.reduce( - (acc, sort) => { - acc[`sort`] = `${sort.field},${sort.direction}`; - return acc; - }, - {} as { [key: string]: string }, - ), - }); + const queryParams = createQueryParams(pagination); return fetchData>( `${url}?${queryParams.toString()}`, diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index d46d266..e92d691 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -1,9 +1,9 @@ -export async function apiClient( +export async function apiClient( url: string, method: "GET" | "POST" | "PUT" | "DELETE", body?: FormData | object, authToken?: string, -): Promise { +): Promise { const headers: HeadersInit = { "Access-Control-Allow-Origin": "*", }; @@ -36,5 +36,5 @@ export async function apiClient( if (!response.ok) { throw new Error(`Error: ${response.status} - ${response.statusText}`); } - return response; + return (await response.json()) as T; } diff --git a/src/providers/permissions-provider.tsx b/src/providers/permissions-provider.tsx index 632df72..94ba2ff 100644 --- a/src/providers/permissions-provider.tsx +++ b/src/providers/permissions-provider.tsx @@ -47,13 +47,12 @@ const fetchPermissions = async ( userId: number, authToken: string, ): Promise => { - const response = await apiClient( + return apiClient>( `/api/users/permissions/${userId}`, "GET", undefined, authToken, ); - return (await response.json()) as Array; }; // PermissionsProvider component diff --git a/src/types/query.ts b/src/types/query.ts index ec4f564..bc12ebd 100644 --- a/src/types/query.ts +++ b/src/types/query.ts @@ -64,3 +64,17 @@ export const paginationSchema = z.object({ ) .optional(), }); + +export const createQueryParams = (pagination: Pagination): URLSearchParams => { + return new URLSearchParams({ + page: pagination.page.toString(), + size: pagination.size.toString(), + ...pagination.sort?.reduce( + (acc, sort) => { + acc[`sort`] = `${sort.field},${sort.direction}`; + return acc; + }, + {} as { [key: string]: string }, + ), + }); +};