diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index 38cf0160c..c7dd071fb 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -752,7 +752,17 @@ public class Controller : ControllerBase return installation.HideParentIfUserHasNoAccessToParent(session!.User); } - + + [HttpGet(nameof(GetNetworkProviders))] + public ActionResult> GetNetworkProviders(Token authToken) + { + var session = Db.GetSession(authToken); + if (session is null) + return Unauthorized(); + + return Ok(NetworkProviderService.GetProviders()); + } + [HttpPost(nameof(AcknowledgeError))] public ActionResult AcknowledgeError(Int64 id, Token authToken) { diff --git a/csharp/App/Backend/DataTypes/Installation.cs b/csharp/App/Backend/DataTypes/Installation.cs index 70406e08c..c1b612192 100644 --- a/csharp/App/Backend/DataTypes/Installation.cs +++ b/csharp/App/Backend/DataTypes/Installation.cs @@ -54,4 +54,5 @@ public class Installation : TreeNode public String OrderNumbers { get; set; } public String VrmLink { get; set; } = ""; public string Configuration { get; set; } = ""; + public string NetworkProvider { get; set; } = ""; } \ No newline at end of file diff --git a/csharp/App/Backend/Program.cs b/csharp/App/Backend/Program.cs index 54173a974..9183cc634 100644 --- a/csharp/App/Backend/Program.cs +++ b/csharp/App/Backend/Program.cs @@ -28,6 +28,7 @@ public static class Program LoadEnvFile(); DiagnosticService.Initialize(); TicketDiagnosticService.Initialize(); + NetworkProviderService.Initialize(); AlarmReviewService.StartDailyScheduler(); // DailyIngestionService.StartScheduler(); // Phase 2: enable when S3 auto-push is ready // ReportAggregationService.StartScheduler(); // Phase 2: enable when scheduler should auto-run monthly/yearly diff --git a/csharp/App/Backend/Services/NetworkProviderService.cs b/csharp/App/Backend/Services/NetworkProviderService.cs new file mode 100644 index 000000000..13e6a90eb --- /dev/null +++ b/csharp/App/Backend/Services/NetworkProviderService.cs @@ -0,0 +1,78 @@ +using Flurl.Http; +using Newtonsoft.Json.Linq; + +namespace InnovEnergy.App.Backend.Services; + +/// +/// Fetches and caches the list of Swiss electricity network providers (Netzbetreiber) +/// from the ELCOM/LINDAS SPARQL endpoint. Refreshes every 24 hours. +/// +public static class NetworkProviderService +{ + private static IReadOnlyList _providers = Array.Empty(); + private static Timer? _refreshTimer; + + private const string SparqlEndpoint = "https://ld.admin.ch/query"; + + private const string SparqlQuery = @" +PREFIX schema: +SELECT DISTINCT ?name +FROM +WHERE { + ?operator a schema:Organization ; + schema:name ?name . +} +ORDER BY ?name"; + + public static void Initialize() + { + // Fire-and-forget initial load + Task.Run(RefreshAsync); + + // Refresh every 24 hours + _refreshTimer = new Timer( + _ => Task.Run(RefreshAsync), + null, + TimeSpan.FromHours(24), + TimeSpan.FromHours(24) + ); + + Console.WriteLine("[NetworkProviderService] initialised."); + } + + public static IReadOnlyList GetProviders() => _providers; + + private static async Task RefreshAsync() + { + try + { + var response = await SparqlEndpoint + .WithHeader("Accept", "application/sparql-results+json") + .PostUrlEncodedAsync(new { query = SparqlQuery }); + + var json = await response.GetStringAsync(); + var parsed = JObject.Parse(json); + + var names = parsed["results"]?["bindings"]? + .Select(b => b["name"]?["value"]?.ToString()) + .Where(n => !string.IsNullOrWhiteSpace(n)) + .Distinct() + .OrderBy(n => n) + .ToList(); + + if (names is { Count: > 0 }) + { + _providers = names!; + Console.WriteLine($"[NetworkProviderService] Loaded {names.Count} providers from ELCOM."); + } + else + { + Console.Error.WriteLine("[NetworkProviderService] SPARQL query returned no results."); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"[NetworkProviderService] Failed to fetch providers: {ex.Message}"); + } + } +} diff --git a/typescript/frontend-marios2/src/content/dashboards/Information/Information.tsx b/typescript/frontend-marios2/src/content/dashboards/Information/Information.tsx index f5b5975a7..01667ed5c 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Information/Information.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Information/Information.tsx @@ -1,5 +1,6 @@ import { Alert, + Autocomplete, Box, CardContent, CircularProgress, @@ -14,13 +15,14 @@ import { import { FormattedMessage } from 'react-intl'; import Button from '@mui/material/Button'; import { Close as CloseIcon } from '@mui/icons-material'; -import React, { useContext, useState } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import { I_S3Credentials } from '../../../interfaces/S3Types'; import { I_Installation } from '../../../interfaces/InstallationTypes'; import { InstallationsContext } from '../../../contexts/InstallationsContextProvider'; import { UserContext } from '../../../contexts/userContext'; import { UserType } from '../../../interfaces/UserTypes'; import { ProductIdContext } from '../../../contexts/ProductIdContextProvider'; +import axiosConfig from '../../../Resources/axiosConfig'; import { useLocation, useNavigate } from 'react-router-dom'; @@ -53,6 +55,24 @@ function Information(props: InformationProps) { deleteInstallation } = installationContext; + const canEdit = currentUser.userType == UserType.admin; + const isPartner = currentUser.userType == UserType.partner; + const isSodistore = formValues.product === 3 || formValues.product === 4; + + const [networkProviders, setNetworkProviders] = useState([]); + const [loadingProviders, setLoadingProviders] = useState(false); + + useEffect(() => { + if (isSodistore) { + setLoadingProviders(true); + axiosConfig + .get('/GetNetworkProviders') + .then((res) => setNetworkProviders(res.data)) + .catch(() => {}) + .finally(() => setLoadingProviders(false)); + } + }, []); + const handleChange = (e) => { const { name, value } = e.target; setFormValues({ @@ -286,6 +306,54 @@ function Information(props: InformationProps) { error={formValues.country === ''} /> + {isSodistore && ( +
+ + setFormValues({ + ...formValues, + networkProvider: (val as string) || '' + }) + } + onInputChange={(_e, val) => + setFormValues({ + ...formValues, + networkProvider: val || '' + }) + } + disabled={!canEdit && !isPartner} + loading={loadingProviders} + renderInput={(params) => ( + + } + variant="outlined" + fullWidth + InputProps={{ + ...params.InputProps, + endAdornment: ( + <> + {loadingProviders ? ( + + ) : null} + {params.InputProps.endAdornment} + + ) + }} + /> + )} + /> +
+ )} +
- {currentUser.userType == UserType.admin && ( + {canEdit && ( <>
- {currentUser.userType == UserType.admin && ( + {canEdit && ( )} - {currentUser.userType == UserType.admin && ( + {(canEdit || (isPartner && isSodistore)) && (
+
+ + setFormValues({ + ...formValues, + networkProvider: (val as string) || '' + }) + } + onInputChange={(_e, val) => + setFormValues({ + ...formValues, + networkProvider: val || '' + }) + } + disabled={!canEdit && !isPartner} + loading={loadingProviders} + renderInput={(params) => ( + + } + variant="outlined" + fullWidth + InputProps={{ + ...params.InputProps, + endAdornment: ( + <> + {loadingProviders ? ( + + ) : null} + {params.InputProps.endAdornment} + + ) + }} + /> + )} + /> +
+ {canEdit && (