diff --git a/core/src/main/java/de/srsoftware/umbrella/core/model/Project.java b/core/src/main/java/de/srsoftware/umbrella/core/model/Project.java index cbe79e6..f5a84dc 100644 --- a/core/src/main/java/de/srsoftware/umbrella/core/model/Project.java +++ b/core/src/main/java/de/srsoftware/umbrella/core/model/Project.java @@ -1,6 +1,7 @@ /* © SRSoftware 2025 */ package de.srsoftware.umbrella.core.model; +import static de.srsoftware.tools.Optionals.nullable; import static de.srsoftware.umbrella.core.Constants.*; import static de.srsoftware.umbrella.core.Util.markdown; @@ -8,9 +9,39 @@ import de.srsoftware.tools.Mappable; import java.sql.ResultSet; import java.sql.SQLException; import java.util.*; +import org.json.JSONObject; -public record Project(long id, String name, String description, Status status, Long companyId, boolean showClosed, Collection members) implements Mappable { +public class Project implements Mappable { + private final Collection members; + private final boolean showClosed; + private final Long companyId; + private Status status; + private String name; + private final long id; + private String description; + private final Set dirtyFields = new HashSet<>(); + public Project(long id, String name, String description, Status status, Long companyId, boolean showClosed, Collection members) { + this.id = id; + this.name = name; + this.description = description; + this.status = status; + this.companyId = companyId; + this.showClosed = showClosed; + this.members = members; + } + + public Optional companyId(){ + return nullable(companyId); + } + + public String description(){ + return description; + } + + public Set dirtyFields(){ + return dirtyFields; + } public boolean hasMember(UmbrellaUser user) { for (var member : members){ @@ -19,12 +50,44 @@ public record Project(long id, String name, String description, Status status, L return false; } + public long id(){ + return id; + } + + public Collection members(){ + return members; + } + + public String name(){ + return name; + } public static Project of(ResultSet rs) throws SQLException { var companyId = rs.getLong(COMPANY_ID); return new Project(rs.getLong(ID),rs.getString(NAME),rs.getString(DESCRIPTION),Status.of(rs.getInt(STATUS)),companyId == 0 ? null : companyId,rs.getBoolean(SHOW_CLOSED),new ArrayList<>()); } + public Project patch(JSONObject json) { + for (var key : json.keySet()){ + switch (key){ + case DESCRIPTION: description = json.getString(key); break; + case NAME: name = json.getString(key); break; + case STATUS: status = Status.of(json.getInt(key)); break; + default: key = null; + } + if (key != null) dirtyFields.add(key); + } + return this; + } + + public boolean showClosed(){ + return showClosed; + } + + public Status status(){ + return status; + } + @Override public Map toMap() { var map = new HashMap(); @@ -37,4 +100,12 @@ public record Project(long id, String name, String description, Status status, L map.put(MEMBERS,members == null ? List.of() : members.stream().map(Member::toMap).toList()); return map; } + + public boolean isDirty() { + return !dirtyFields.isEmpty(); + } + + public void clean() { + dirtyFields.clear(); + } } diff --git a/frontend/src/Components/LineEditor.svelte b/frontend/src/Components/LineEditor.svelte index 2ff6488..05a253d 100644 --- a/frontend/src/Components/LineEditor.svelte +++ b/frontend/src/Components/LineEditor.svelte @@ -1,5 +1,6 @@ @@ -49,9 +51,11 @@ {#if editable && editing} {:else} -
- {#each value.split("\n") as line} - {line}
- {/each} -
+ {#if value} +
+ {#each value.split("\n") as line} + {line}
+ {/each} +
+ {/if} {/if} \ No newline at end of file diff --git a/frontend/src/Components/TaskList.svelte b/frontend/src/Components/TaskList.svelte index 4a74ff9..b0e3dca 100644 --- a/frontend/src/Components/TaskList.svelte +++ b/frontend/src/Components/TaskList.svelte @@ -11,6 +11,6 @@
    {#each sortedTasks as task} - + {/each}
\ No newline at end of file diff --git a/frontend/src/routes/project/View.svelte b/frontend/src/routes/project/View.svelte index 22a5200..6bc4908 100644 --- a/frontend/src/routes/project/View.svelte +++ b/frontend/src/routes/project/View.svelte @@ -2,6 +2,8 @@ import { t } from '../../translations.svelte.js'; import { onMount } from 'svelte'; import TaskList from '../../Components/TaskList.svelte'; + import MarkdownEditor from '../../Components/MarkdownEditor.svelte'; + import LineEditor from '../../Components/LineEditor.svelte'; let { id } = $props(); let project = $state(null); @@ -36,6 +38,21 @@ } } + async function update(data){ + const url = `${location.protocol}//${location.host.replace('5173','8080')}/api/project/${id}`; + const resp = await fetch(url,{ + credentials:'include', + method:'PATCH', + body:JSON.stringify(data) + }); + if (resp.ok){ + error = null; + project = await resp.json(); + } else { + error = await resp.text(); + } + } + onMount(loadProject); @@ -48,7 +65,9 @@ {t('project')} - {project.name} + + update({name:val})} /> + {#if project.company} @@ -66,7 +85,9 @@ {t('description')} - {@html project.description.rendered} + + update({description:val})} /> + {#if estimated_time.sum} @@ -78,7 +99,7 @@ {t('tasks')} {#if tasks} - + {/if} diff --git a/project/src/main/java/de/srsoftware/umbrella/project/ProjectModule.java b/project/src/main/java/de/srsoftware/umbrella/project/ProjectModule.java index cd2c8ce..f972978 100644 --- a/project/src/main/java/de/srsoftware/umbrella/project/ProjectModule.java +++ b/project/src/main/java/de/srsoftware/umbrella/project/ProjectModule.java @@ -1,7 +1,6 @@ /* © SRSoftware 2025 */ package de.srsoftware.umbrella.project; -import static de.srsoftware.tools.Optionals.nullable; import static de.srsoftware.umbrella.core.ConnectionProvider.connect; import static de.srsoftware.umbrella.core.Constants.*; import static de.srsoftware.umbrella.core.Paths.LIST; @@ -10,6 +9,8 @@ import static de.srsoftware.umbrella.core.model.Permission.OWNER; import static de.srsoftware.umbrella.core.model.Status.OPEN; import static de.srsoftware.umbrella.project.Constants.CONFIG_DATABASE; import static java.lang.Boolean.TRUE; +import static java.net.HttpURLConnection.HTTP_NOT_IMPLEMENTED; +import static java.net.HttpURLConnection.HTTP_OK; import static java.util.Comparator.comparing; import com.sun.net.httpserver.HttpExchange; @@ -69,6 +70,35 @@ public class ProjectModule extends BaseHandler implements ProjectService { } } + @Override + public boolean doOptions(Path path, HttpExchange ex) throws IOException { + addCors(ex); + return sendEmptyResponse(HTTP_OK,ex); + } + + @Override + public boolean doPatch(Path path, HttpExchange ex) throws IOException { + addCors(ex); + try { + Optional token = SessionToken.from(ex).map(Token::of); + var user = users.loadUser(token); + if (user.isEmpty()) return unauthorized(ex); + var head = path.pop(); + return switch (head) { + default -> { + var projectId = Long.parseLong(head); + head = path.pop(); + yield switch (head){ + case null -> patchProject(ex,projectId,user.get()); + default -> super.doGet(path,ex); + }; + } + }; + } catch (UmbrellaException e){ + return send(ex,e); + } + } + @Override public boolean doPost(Path path, HttpExchange ex) throws IOException { addCors(ex); @@ -98,7 +128,7 @@ public class ProjectModule extends BaseHandler implements ProjectService { members.put(userId,Map.of(USER,users.loadUser(userId).toMap(),PERMISSION,perm)); } if (!members.isEmpty()) map.put(MEMBERS,members); - nullable(project.companyId()).map(companies::get).map(Company::toMap).ifPresent(data -> map.put(COMPANY,data)); + project.companyId().map(companies::get).map(Company::toMap).ifPresent(data -> map.put(COMPANY,data)); return sendContent(ex,map); } @@ -145,6 +175,14 @@ public class ProjectModule extends BaseHandler implements ProjectService { return sendContent(ex,projects); } + private boolean patchProject(HttpExchange ex, long projectId, UmbrellaUser user) throws IOException, UmbrellaException { + var project = projects.load(projectId); + if (!project.hasMember(user)) throw forbidden("You are not a member of {0}",project.name()); + var json = json(ex); + projects.save(project.patch(json)); + return sendContent(ex,project.toMap()); + } + private boolean postProject(HttpExchange ex, UmbrellaUser user) throws IOException, UmbrellaException { var json = json(ex); diff --git a/project/src/main/java/de/srsoftware/umbrella/project/SqliteDb.java b/project/src/main/java/de/srsoftware/umbrella/project/SqliteDb.java index 03652d7..e400f54 100644 --- a/project/src/main/java/de/srsoftware/umbrella/project/SqliteDb.java +++ b/project/src/main/java/de/srsoftware/umbrella/project/SqliteDb.java @@ -2,9 +2,8 @@ package de.srsoftware.umbrella.project; import static de.srsoftware.tools.jdbc.Condition.*; +import static de.srsoftware.tools.jdbc.Query.*; import static de.srsoftware.tools.jdbc.Query.SelectQuery.ALL; -import static de.srsoftware.tools.jdbc.Query.insertInto; -import static de.srsoftware.tools.jdbc.Query.select; import static de.srsoftware.umbrella.core.Constants.*; import static de.srsoftware.umbrella.core.Constants.TABLE_SETTINGS; import static de.srsoftware.umbrella.core.ResponseCode.HTTP_SERVER_ERROR; @@ -190,26 +189,36 @@ CREATE TABLE IF NOT EXISTS {0} ( {1} VARCHAR(255) PRIMARY KEY, {2} VARCHAR(255) @Override public Project save(Project prj) throws UmbrellaException { - try { - if (prj.id() == 0) { - var stmt = insertInto(TABLE_PROJECTS, NAME, DESCRIPTION, STATUS, COMPANY_ID, SHOW_CLOSED).values(prj.name(), prj.description(), prj.status().code(), prj.companyId(), prj.showClosed()).execute(db); - var rs = stmt.getGeneratedKeys(); - var id = rs.next() ? rs.getLong(1) : null; - rs.close(); - if (id != null){ - if (!prj.members().isEmpty()) { - var query = insertInto(TABLE_PROJECT_USERS, PROJECT_ID, USER_ID, PERMISSIONS); - for (var member : prj.members()) query.values(id, member.userId(), member.permission().code()); - query.execute(db).close(); + if (prj.id() == 0) { // new + try { + var stmt = insertInto(TABLE_PROJECTS, NAME, DESCRIPTION, STATUS, COMPANY_ID, SHOW_CLOSED).values(prj.name(), prj.description(), prj.status().code(), prj.companyId().orElse(null), prj.showClosed()).execute(db); + var rs = stmt.getGeneratedKeys(); + var id = rs.next() ? rs.getLong(1) : null; + rs.close(); + if (id != null){ + if (!prj.members().isEmpty()) { + var query = insertInto(TABLE_PROJECT_USERS, PROJECT_ID, USER_ID, PERMISSIONS); + for (var member : prj.members()) query.values(id, member.userId(), member.permission().code()); + query.execute(db).close(); + } + return new Project(id, prj.name(), prj.description(),prj.status(),prj.companyId().orElse(null),prj.showClosed(),prj.members()); + } + } catch (SQLException e) { + throw new UmbrellaException(HTTP_SERVER_ERROR,"Failed to insert project into database"); + } + } else { // Update + try { + if (prj.isDirty()){ + update(TABLE_PROJECTS).set(NAME,DESCRIPTION,STATUS,COMPANY_ID,SHOW_CLOSED).where(ID,equal(prj.id())).prepare(db) + .apply(prj.name(),prj.description(),prj.status().code(),prj.companyId(),prj.showClosed()) + .execute(); + prj.clean(); } - return new Project(id, prj.name(), prj.description(),prj.status(),prj.companyId(),prj.showClosed(),prj.members()); + return prj; + } catch (SQLException e) { + throw new UmbrellaException(HTTP_SERVER_ERROR,"Failed to update project in database"); } - } else { - LOG.log(ERROR,"Updating project not implemented!"); } return null; - } catch (SQLException e) { - throw new UmbrellaException(HTTP_SERVER_ERROR,"Failed to insert project into database"); - } } } diff --git a/translations/src/main/resources/de.json b/translations/src/main/resources/de.json index 77ac2da..cf5c922 100644 --- a/translations/src/main/resources/de.json +++ b/translations/src/main/resources/de.json @@ -49,6 +49,7 @@ "documents": "Dokumente", "do_login" : "anmelden", "do_send" : "versenden", + "double_click_to_edit": "Doppel-klicken zum Bearbeiten", "edit": "Bearbeiten", "editing": "Nutzer {0} bearbeiten", @@ -97,7 +98,8 @@ "members": "Mitarbeiter", "message": "Nachricht", "messages": "Benachrichtigungen", - "model": "Modelle", + "model": "Modell", + "models": "Modelle", "module": { "bookmark": "Lesezeichen", "commons": " ", @@ -184,6 +186,7 @@ "tax_id": "Steuernummer", "tax_rate": "Steuersatz", "theme": "Design", + "times": "Zeiterfassung", "timetrack": "Zeiterfassung", "title_or_desc": "Titel/Beschreibung", "tutorial": "Tutorial",