Compare commits
34 Commits
feature/st
...
module/pol
| Author | SHA1 | Date | |
|---|---|---|---|
| 880816450c | |||
| 96432bcb5b | |||
| b95cdd32ec | |||
| 1b17c8fb09 | |||
| 779488ab1f | |||
| b6a025a55b | |||
| 2cd99f362b | |||
| 577e99d840 | |||
| c9b9bb9fe5 | |||
| b4f19ff1c8 | |||
| 67d28ec498 | |||
| 97fdfb3d55 | |||
| 9ab1e479eb | |||
| 69d3aacc53 | |||
| 702b9dadd5 | |||
| 02d0f829c8 | |||
| 6125bc62a2 | |||
| 1589bbe471 | |||
| 800ac35997 | |||
| f78de1caae | |||
| 5336384e0d | |||
| 4a2c49c42c | |||
| cbc6ce188d | |||
| e5ab655787 | |||
| 0eab5619d1 | |||
| dd8eefed61 | |||
| a25736eff3 | |||
| dbc4525e80 | |||
| 0db2ad8b0e | |||
| 4343ff7d7a | |||
| 6e7bb08738 | |||
| 1e29aa8583 | |||
| 214a4c00f5 | |||
| 12ed6d47ec |
@@ -23,6 +23,7 @@ dependencies{
|
||||
implementation(project(":markdown"))
|
||||
implementation(project(":messages"))
|
||||
implementation(project(":notes"))
|
||||
implementation(project(":poll"))
|
||||
implementation(project(":project"))
|
||||
implementation(project(":stock"))
|
||||
implementation(project(":tags"))
|
||||
|
||||
@@ -23,6 +23,7 @@ import de.srsoftware.umbrella.markdown.MarkdownApi;
|
||||
import de.srsoftware.umbrella.message.MessageSystem;
|
||||
import de.srsoftware.umbrella.messagebus.MessageApi;
|
||||
import de.srsoftware.umbrella.notes.NoteModule;
|
||||
import de.srsoftware.umbrella.poll.PollModule;
|
||||
import de.srsoftware.umbrella.project.ProjectModule;
|
||||
import de.srsoftware.umbrella.stock.StockModule;
|
||||
import de.srsoftware.umbrella.tags.TagModule;
|
||||
@@ -81,6 +82,7 @@ public class Application {
|
||||
new MarkdownApi().bindPath("/api/markdown").on(server);
|
||||
new NoteModule(config).bindPath("/api/notes").on(server);
|
||||
new StockModule(config).bindPath("/api/stock").on(server);
|
||||
new PollModule(config).bindPath("/api/poll").on(server);
|
||||
new ProjectModule(config).bindPath("/api/project").on(server);
|
||||
new ProjectLegacy(config).bindPath("/legacy/project").on(server);
|
||||
new TaskModule(config).bindPath("/api/task").on(server);
|
||||
|
||||
@@ -12,6 +12,7 @@ public class ModuleRegistry {
|
||||
private FileService fileService;
|
||||
private MarkdownService markdownService;
|
||||
private NoteService noteService;
|
||||
private PollService pollService;
|
||||
private PostBox postBox;
|
||||
private ProjectService projectService;
|
||||
private StockService stockService;
|
||||
@@ -33,9 +34,10 @@ public class ModuleRegistry {
|
||||
case ContactService cs: singleton.contactService = cs; break;
|
||||
case DocumentService ds: singleton.documentService = ds; break;
|
||||
case FileService fs: singleton.fileService = fs; break;
|
||||
case StockService is: singleton.stockService = is; break;
|
||||
case StockService is: singleton.stockService = is; break;
|
||||
case MarkdownService ms: singleton.markdownService = ms; break;
|
||||
case NoteService ns: singleton.noteService = ns; break;
|
||||
case PollService ps: singleton.pollService = ps; break;
|
||||
case PostBox pb: singleton.postBox = pb; break;
|
||||
case ProjectService ps: singleton.projectService = ps; break;
|
||||
case TagService ts: singleton.tagService = ts; break;
|
||||
@@ -81,6 +83,10 @@ public class ModuleRegistry {
|
||||
return singleton.noteService;
|
||||
}
|
||||
|
||||
public static PollService pollService() {
|
||||
return singleton.pollService;
|
||||
}
|
||||
|
||||
public static PostBox postBox() {
|
||||
return singleton.postBox;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/* © SRSoftware 2025 */
|
||||
package de.srsoftware.umbrella.core.api;
|
||||
|
||||
public interface PollService {
|
||||
}
|
||||
@@ -51,10 +51,12 @@ public class Field {
|
||||
public static final String DUE_DATE = "due_date";
|
||||
public static final String DURATION = "duration";
|
||||
|
||||
public static final String EDITOR = "editor";
|
||||
public static final String EMAIL = "email";
|
||||
public static final String END_TIME = "end_time";
|
||||
public static final String ENTITY_ID = "entity_id";
|
||||
public static final String EST_TIME = "est_time";
|
||||
public static final String EVALUATION = "evaluation";
|
||||
public static final String EXPECTED = "expected";
|
||||
public static final String EXPIRATION = "expiration";
|
||||
|
||||
@@ -102,7 +104,9 @@ public class Field {
|
||||
|
||||
public static final String OBJECT = "object";
|
||||
public static final String OFFSET = "offset";
|
||||
public static final String OPTIONS = "options";
|
||||
public static final String OPTIONAL = "optional";
|
||||
public static final String OPTION_ID = "option_id";
|
||||
public static final String OWNER = "owner";
|
||||
public static final String OWNER_NUMBER = "owner_number";
|
||||
|
||||
@@ -113,11 +117,13 @@ public class Field {
|
||||
public static final String PATH = "path";
|
||||
public static final String PERMISSION = "permission";
|
||||
public static final String PHONE = "phone";
|
||||
public static final String POLL_ID = "poll_id";
|
||||
public static final String POS = "pos";
|
||||
public static final String POSITIONS = "positions";
|
||||
public static final String PRICE = "single_price";
|
||||
public static final String PRICE_FORMAT = "price_format";
|
||||
public static final String PRIORITY = "priority";
|
||||
public static final String PRIVATE = "private";
|
||||
public static final String PROJECT = "project";
|
||||
public static final String PROJECT_ID = "project_id";
|
||||
public static final String PROPERTIES = "properties";
|
||||
@@ -127,15 +133,18 @@ public class Field {
|
||||
public static final String RENDERED = "rendered";
|
||||
public static final String REQUIRED_TASKS_IDS = "required_tasks_ids";
|
||||
|
||||
public static final String SELECTION = "selection";
|
||||
public static final String SENDER = "sender";
|
||||
public static final String SENDER_USER_ID = "sender_user_id";
|
||||
public static final String SETTINGS = "settings";
|
||||
public static final String SHARES = "shares";
|
||||
public static final String SHOW_CLOSED = "show_closed";
|
||||
public static final String SILENT = "silent";
|
||||
public static final String SOURCE = "source";
|
||||
public static final String START_DATE = "start_date";
|
||||
public static final String START_TIME = "start_time";
|
||||
public static final String STATE = "state";
|
||||
public static final String STATS = "stats";
|
||||
public static final String STATUS = "status";
|
||||
public static final String STATUS_CODE = "code";
|
||||
public static final String SUBJECT = "subject";
|
||||
@@ -178,4 +187,6 @@ public class Field {
|
||||
public static final String VERSION = "version";
|
||||
public static final String VERSIONS = "versions";
|
||||
|
||||
public static final String WEIGHT = "weight";
|
||||
public static final String WEIGHTS = "weights";
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ public class Path {
|
||||
public static final String COMPANY = "company";
|
||||
public static final String CONNECTED = "connected";
|
||||
|
||||
public static final String EVALUATE = "evaluate";
|
||||
|
||||
public static final String ITEM = "item";
|
||||
|
||||
public static final String JSON = "json";
|
||||
@@ -27,16 +29,20 @@ public class Path {
|
||||
|
||||
public static final String MENU = "menu";
|
||||
|
||||
public static final String PAGE = "page";
|
||||
public static final String PASSWORD = "password";
|
||||
public static final String PROJECT = "project";
|
||||
public static final String PROPERTIES = "properties";
|
||||
public static final String OPTION = "option";
|
||||
|
||||
public static final String PAGE = "page";
|
||||
public static final String PASSWORD = "password";
|
||||
public static final String PERMISSIONS = "permissions";
|
||||
public static final String PROJECT = "project";
|
||||
public static final String PROPERTIES = "properties";
|
||||
public static final String PROPERTY = "property";
|
||||
|
||||
public static final String READ = "read";
|
||||
public static final String REDIRECT = "redirect";
|
||||
|
||||
public static final String SEARCH = "search";
|
||||
public static final String SELECT = "select";
|
||||
public static final String SETTINGS = "settings";
|
||||
public static final String STATES = "states";
|
||||
public static final String STARTED = "started";
|
||||
@@ -49,4 +55,5 @@ public class Path {
|
||||
public static final String USER = "user";
|
||||
public static final String USES = "uses";
|
||||
|
||||
public static final String WEIGHT = "weight";
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ public class Text {
|
||||
public static final String DOCUMENT_WITH_ID = "document ({id})";
|
||||
|
||||
public static final String EMAILS_FOR_RECEIVER = "emails for {email}";
|
||||
public static final String EVALUATION = "evaluation";
|
||||
|
||||
public static final String FILES = "files";
|
||||
|
||||
@@ -39,12 +40,18 @@ public class Text {
|
||||
public static final String MESSAGE = "message";
|
||||
public static final String MESSAGES = "messages";
|
||||
|
||||
public static final String NOTE = "note";
|
||||
public static final String NOTES = "notes";
|
||||
public static final String NOTE_WITH_ID = "note ({id})";
|
||||
public static final String NUMBER = "number";
|
||||
public static final String NOT_ALLOWED_TO_EDIT = "You are not allowed to edit {object}!";
|
||||
public static final String NOT_ALLOWED_TO_EVALUATE = "You are not allowed to evaluate this {object}!";
|
||||
public static final String NOTE = "note";
|
||||
public static final String NOTES = "notes";
|
||||
public static final String NOTE_WITH_ID = "note ({id})";
|
||||
public static final String NUMBER = "number";
|
||||
|
||||
public static final Object OPTION = "option"
|
||||
;
|
||||
public static final String PATH = "path";
|
||||
public static final String PERMISSION = "permission";
|
||||
public static final String POLL = "poll";
|
||||
public static final String POLLS = "polls";
|
||||
public static final String PROJECTS = "projects";
|
||||
public static final String PROJECT_WITH_ID = "project ({id})";
|
||||
@@ -54,6 +61,7 @@ public class Text {
|
||||
public static final String RECEIVER = "receiver";
|
||||
public static final String RECEIVERS = "receivers";
|
||||
|
||||
public static final String SELECTIONS = "selections";
|
||||
public static final String SENDER = "sender";
|
||||
public static final String SERVICE_WITH_ID = "service ({id})";
|
||||
public static final String SESSION = "session";
|
||||
@@ -72,12 +80,14 @@ public class Text {
|
||||
public static final String UNIT = "unit";
|
||||
public static final String USER_WITH_ID = "user ({id})";
|
||||
|
||||
public static final String WEIGHT = "weight";
|
||||
public static final String WIKI = "wiki";
|
||||
public static final String WIKI_PAGE = "wiki page";
|
||||
public static final String WIKI_PAGES = "wiki pages";
|
||||
|
||||
public static final String UNIT_PRICE = "unit price";
|
||||
public static final String USER = "user";
|
||||
public static final String USERS = "users";
|
||||
public static final String UNIT_PRICE = "unit price";
|
||||
public static final String UNKNOWN_FIELD = "unknown field: {id}";
|
||||
public static final String USER = "user";
|
||||
public static final String USERS = "users";
|
||||
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ public enum Permission implements Mappable {
|
||||
for (var p : Permission.values()){
|
||||
if (p.code == code) return p;
|
||||
}
|
||||
throw new InvalidParameterException(format("{0} is not a valid permission code"));
|
||||
throw new InvalidParameterException(format("{0} is not a valid permission code",code));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
309
core/src/main/java/de/srsoftware/umbrella/core/model/Poll.java
Normal file
309
core/src/main/java/de/srsoftware/umbrella/core/model/Poll.java
Normal file
@@ -0,0 +1,309 @@
|
||||
package de.srsoftware.umbrella.core.model;
|
||||
|
||||
import de.srsoftware.tools.Mappable;
|
||||
import de.srsoftware.umbrella.core.ModuleRegistry;
|
||||
import de.srsoftware.umbrella.core.Util;
|
||||
import de.srsoftware.umbrella.core.api.Owner;
|
||||
import de.srsoftware.umbrella.core.constants.Field;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static de.srsoftware.umbrella.core.constants.Field.*;
|
||||
import static java.text.MessageFormat.format;
|
||||
|
||||
public class Poll implements Mappable {
|
||||
public static class Option implements Mappable{
|
||||
|
||||
private int id;
|
||||
|
||||
Integer status;
|
||||
private String description;
|
||||
private String name;
|
||||
private final Set<String> dirtyFields = new HashSet<>();
|
||||
public Option(int id, String name, String description, Integer status) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.description = description;
|
||||
this.status = status;
|
||||
}
|
||||
public String description(){
|
||||
return description;
|
||||
}
|
||||
|
||||
public Option description(String newVal){
|
||||
description = newVal;
|
||||
dirtyFields.add(DESCRIPTION);
|
||||
return this;
|
||||
}
|
||||
|
||||
public int id(){
|
||||
return id;
|
||||
}
|
||||
|
||||
public Option id(Integer newVal){
|
||||
this.id = newVal;
|
||||
dirtyFields.add(ID);
|
||||
return this;
|
||||
}
|
||||
|
||||
public boolean isDirty(){
|
||||
return !dirtyFields.isEmpty();
|
||||
}
|
||||
|
||||
public boolean isNew(){
|
||||
return dirtyFields.contains(ID);
|
||||
}
|
||||
|
||||
public String name(){
|
||||
return name;
|
||||
}
|
||||
|
||||
public Option name(String newVal){
|
||||
name = newVal;
|
||||
dirtyFields.add(NAME);
|
||||
return this;
|
||||
}
|
||||
|
||||
public static Option of(ResultSet rs) throws SQLException {
|
||||
var id = rs.getInt(ID);
|
||||
var name = rs.getString(NAME);
|
||||
var description = rs.getString(DESCRIPTION);
|
||||
var status = rs.getInt(STATUS);
|
||||
|
||||
return new Option(id,name,description,status);
|
||||
}
|
||||
|
||||
public Integer status(){
|
||||
return status;
|
||||
}
|
||||
|
||||
public Option status(int newValue){
|
||||
this.status = newValue;
|
||||
dirtyFields.add(STATUS);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> toMap() {
|
||||
return Map.of(
|
||||
ID,id,
|
||||
NAME,name,
|
||||
DESCRIPTION, Map.of(
|
||||
SOURCE,description,
|
||||
RENDERED,Util.markdown(description)
|
||||
),
|
||||
STATUS,status
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return format("Option \"{0}\"",name);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
public static class Evaluation {
|
||||
private static class Histogram extends HashMap<Integer,Integer>{
|
||||
public Double average() {
|
||||
var sum = 0;
|
||||
var count = 0;
|
||||
for (var entry : entrySet()){
|
||||
var weight = entry.getKey();
|
||||
var votes = entry.getValue();
|
||||
count += votes;
|
||||
sum += weight * votes;
|
||||
}
|
||||
if (count < 1) return null;
|
||||
return sum/(double) count;
|
||||
}
|
||||
}
|
||||
private static class OptionStats extends HashMap<Integer,Histogram>{}
|
||||
private static class Stats extends HashMap<Double,OptionStats>{
|
||||
public Stats(OptionStats optionStats) {
|
||||
for (var entry : optionStats.entrySet()){
|
||||
var optionId = entry.getKey();
|
||||
var histo = entry.getValue();
|
||||
var average = histo.average();
|
||||
var os = get(average);
|
||||
if (os == null) put(average,os = new OptionStats());
|
||||
os.put(optionId,histo);
|
||||
}
|
||||
}
|
||||
}
|
||||
/*
|
||||
|
||||
{
|
||||
options : {
|
||||
1: option 1
|
||||
2: option 2
|
||||
},
|
||||
stat : {
|
||||
0.571 : { // average
|
||||
1 : { // option
|
||||
-2 : 0, // weight → selections
|
||||
-1 : 1,
|
||||
0 : 2
|
||||
1 : 3
|
||||
2 : 1
|
||||
}
|
||||
2 :
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
private final Map<Integer, Option> options = new HashMap<>();
|
||||
private final OptionStats optionStats = new OptionStats();
|
||||
|
||||
public Evaluation(Poll poll) {
|
||||
for (var option : poll.options){
|
||||
options.put(option.id,option);
|
||||
var histo = new Histogram();
|
||||
for (var w : poll.weights.keySet()) histo.put(w,0);
|
||||
optionStats.put(option.id,histo);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public void count(ResultSet rs) throws SQLException {
|
||||
var optionId = rs.getInt(OPTION_ID);
|
||||
//var userId = rs.getObject(USER);
|
||||
var weight = rs.getInt(WEIGHT);
|
||||
var histogram = optionStats.get(optionId);
|
||||
histogram.put(weight,histogram.get(weight)+1);
|
||||
}
|
||||
|
||||
public Map<Double,?> toMap() {
|
||||
return new Stats(optionStats);
|
||||
}
|
||||
|
||||
}
|
||||
private Owner owner;
|
||||
private String id, name, description;
|
||||
|
||||
private boolean isPrivate;
|
||||
private List<Option> options;
|
||||
private Map<Integer,String> weights;
|
||||
private Map<UmbrellaUser,Permission> permissions;
|
||||
private Set<String> dirtyFields = new HashSet<>();
|
||||
public Poll(String id, Owner owner, String name, String description, boolean isPrivate, List<Option> options, Map<Integer,String> weights){
|
||||
this.id = id;
|
||||
this.owner = owner;
|
||||
this.name = name;
|
||||
this.description = description;
|
||||
this.isPrivate = isPrivate;
|
||||
this.options = new ArrayList<>();
|
||||
this.weights = new HashMap<>();
|
||||
this.permissions = new HashMap<>();
|
||||
}
|
||||
public String description(){
|
||||
return description;
|
||||
}
|
||||
|
||||
public Poll description(String newVal){
|
||||
description = newVal;
|
||||
dirtyFields.add(DESCRIPTION);
|
||||
return this;
|
||||
}
|
||||
|
||||
public String id(){
|
||||
return id;
|
||||
}
|
||||
|
||||
public boolean isDirty(){
|
||||
return !dirtyFields.isEmpty();
|
||||
}
|
||||
|
||||
public boolean isDirty(String ... fields){
|
||||
for (var field : fields){
|
||||
if (dirtyFields.contains(field)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean isPrivate(){
|
||||
return isPrivate;
|
||||
}
|
||||
|
||||
private Map<Long, Map<String, Object>> mapPermissions() {
|
||||
var result = new HashMap<Long,Map<String,Object>>();
|
||||
for (var entry : permissions.entrySet()){
|
||||
var user = entry.getKey();
|
||||
var data = user.toMap();
|
||||
data.put(Field.PERMISSION,entry.getValue().toMap());
|
||||
result.put(user.id(),data);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public String name(){
|
||||
return name;
|
||||
}
|
||||
|
||||
public Poll name(String newVal){
|
||||
name = newVal;
|
||||
dirtyFields.add(NAME);
|
||||
return this;
|
||||
}
|
||||
|
||||
public static Poll of(ResultSet rs) throws SQLException {
|
||||
var id = rs.getString(ID);
|
||||
var userId = rs.getLong(Field.USER_ID);
|
||||
var name = rs.getString(NAME);
|
||||
var description = rs.getString(Field.DESCRIPTION);
|
||||
var isPrivate = rs.getBoolean(Field.PRIVATE);
|
||||
var owner = ModuleRegistry.userService().loadUser(userId);
|
||||
|
||||
return new Poll(id,owner,name,description,isPrivate,new ArrayList<>(),new HashMap<>());
|
||||
}
|
||||
|
||||
public List<Option> options(){
|
||||
return options;
|
||||
}
|
||||
|
||||
public Owner owner(){
|
||||
return owner;
|
||||
}
|
||||
|
||||
public Poll setPrivate(boolean newValue) {
|
||||
if (newValue != isPrivate) dirtyFields.add(PRIVATE);
|
||||
isPrivate = newValue;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Poll setWeight(Integer weight, String description) {
|
||||
weights.put(weight,description);
|
||||
dirtyFields.add(WEIGHTS);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Map<UmbrellaUser, Permission> permissions(){
|
||||
return permissions;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> toMap() {
|
||||
return Map.of(
|
||||
ID, id,
|
||||
Field.OWNER, owner.toMap(),
|
||||
NAME,name,
|
||||
Field.DESCRIPTION, Map.of(
|
||||
Field.SOURCE,description,
|
||||
Field.RENDERED,Util.markdown(description)
|
||||
),
|
||||
Field.OPTIONS, options.stream().collect(Collectors.toMap(Option::id,Option::toMap)),
|
||||
Field.PERMISSION, mapPermissions(),
|
||||
Field.PRIVATE, isPrivate,
|
||||
Field.WEIGHTS, weights
|
||||
);
|
||||
}
|
||||
|
||||
public Map<Integer, String> weights(){
|
||||
return weights;
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,10 @@
|
||||
import ContactList from "./routes/contact/Index.svelte";
|
||||
import DocList from "./routes/document/List.svelte";
|
||||
import EasyList from "./routes/task/EasyList.svelte";
|
||||
import EditPoll from "./routes/poll/Edit.svelte";
|
||||
import EditService from "./routes/user/EditService.svelte";
|
||||
import EditUser from "./routes/user/EditUser.svelte";
|
||||
import EvalPoll from "./routes/poll/Evaluate.svelte";
|
||||
import FileIndex from "./routes/files/Index.svelte";
|
||||
import Footer from "./Components/Footer.svelte";
|
||||
import Kanban from "./routes/project/Kanban.svelte";
|
||||
@@ -25,6 +27,7 @@
|
||||
import Menu from "./Components/Menu.svelte";
|
||||
import NewPage from "./routes/wiki/AddPage.svelte";
|
||||
import Notes from "./routes/notes/Index.svelte";
|
||||
import PollList from "./routes/poll/Index.svelte";
|
||||
import ProjectList from "./routes/project/List.svelte";
|
||||
import ProjectAdd from "./routes/project/Create.svelte";
|
||||
import ResetPw from "./routes/user/ResetPw.svelte";
|
||||
@@ -37,6 +40,7 @@
|
||||
import Times from "./routes/time/Index.svelte";
|
||||
import User from "./routes/user/User.svelte";
|
||||
import ViewDoc from "./routes/document/View.svelte";
|
||||
import ViewPoll from "./routes/poll/View.svelte";
|
||||
import ViewPrj from "./routes/project/View.svelte";
|
||||
import ViewTask from "./routes/task/View.svelte";
|
||||
import WikiIndex from "./routes/wiki/Index.svelte";
|
||||
@@ -95,6 +99,10 @@
|
||||
<Route path="/message" component={Messages} />
|
||||
<Route path="/message/settings" component={MsgSettings} />
|
||||
<Route path="/notes" component={Notes} />
|
||||
<Route path="/poll" component={PollList} />
|
||||
<Route path="/poll/:id/edit" component={EditPoll} />
|
||||
<Route path="/poll/:id/evaluate" component={EvalPoll} />
|
||||
<Route path="/poll/:id/view" component={ViewPoll} />
|
||||
<Route path="/project" component={ProjectList} />
|
||||
<Route path="/project/add" component={ProjectAdd} />
|
||||
<Route path="/project/:project_id/add_task" component={AddTask} />
|
||||
@@ -103,7 +111,8 @@
|
||||
<Route path="/search" component={Search} />
|
||||
<Route path="/stock" component={Stock} />
|
||||
<Route path="/stock/location/:location_id" component={Stock} />
|
||||
<Route path="/stock/:owner/:owner_id/item/:item_id" component={Stock} />
|
||||
<Route path="/stock/:item_id/view" component={Stock} />
|
||||
<Route path="/stock/:owner/:owner_id/item/:owner_number" component={Stock} />
|
||||
<Route path="/tags" component={TagList} />
|
||||
<Route path="/tags/use/:tag" component={TagUses} />
|
||||
<Route path="/task" component={TaskList} />
|
||||
@@ -136,6 +145,7 @@
|
||||
{/if}
|
||||
<Route path="/user/reset/pw" component={ResetPw} />
|
||||
<Route path="/oidc_callback" component={Callback} />
|
||||
<Route path="/poll/:id/view" component={ViewPoll} />
|
||||
<Route path="/wiki/:key/view" component={WikiGuest} />
|
||||
<Route>
|
||||
<Login />
|
||||
|
||||
27
frontend/src/Components/Histogram.svelte
Normal file
27
frontend/src/Components/Histogram.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<script>
|
||||
import { t } from '../translations.svelte';
|
||||
let { data = {} } = $props();
|
||||
|
||||
let max = $derived(Math.max(...Object.values(data)));
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.histo {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding: 0 2px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="histo">
|
||||
{#each Object.entries(data).sort((a,b) => a[0] - b[0]) as [weight,count]}
|
||||
<div class="bar" style="height: {100*count/max}%" title={t('voted {count} times',{count})}>{weight}</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1,4 +1,4 @@
|
||||
<script>
|
||||
<script>
|
||||
import { activeField } from './field_sync.svelte.js';
|
||||
import { t } from '../translations.svelte.js';
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
</style>
|
||||
|
||||
{#if editable && editing}
|
||||
<input bind:value={editValue} onkeyup={typed} autofocus />
|
||||
<input bind:value={editValue} onkeyup={typed} {title} autofocus />
|
||||
{:else}
|
||||
<svelte:element this={type} href={href} onclick={ignore} {onmousedown} {onmouseup} {ontouchstart} {ontouchend} {oncontextmenu} class={{editable}} {title} >{value}</svelte:element>
|
||||
{/if}
|
||||
|
||||
@@ -129,8 +129,8 @@
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="markdown {editing?'editing':''}">
|
||||
{#if editing}
|
||||
<div class="markdown {editing || simple ?'editing':''}">
|
||||
{#if editing || simple}
|
||||
<span class="hint">{@html t('markdown_supported')}</span>
|
||||
{#if stored_source}
|
||||
<span id="restore_markdown" onclick={restore} class="hint">{t('unsaved_content')}</span>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
let {
|
||||
addMember = (entry) => console.log(`no handler for addMember(${entry})`),
|
||||
dropMember = (member) => console.log(`no handler for dropMember(${member})`),
|
||||
getCandidates = text => {},
|
||||
getCandidates = defaultGetCandidates,
|
||||
members,
|
||||
updatePermission = (uid,perm) => console.log(`no handler for updatePermission(${uid}, ${perm})`)
|
||||
} = $props();
|
||||
@@ -18,6 +18,21 @@
|
||||
let permissions = $state(null);
|
||||
let sortedMembers = $derived.by(() => Object.values(members).sort((a, b) => a.user.name.localeCompare(b.user.name)));
|
||||
|
||||
async function defaultGetCandidates(text){
|
||||
const url = api('user/search');
|
||||
const resp = await fetch(url,{
|
||||
credentials : 'include',
|
||||
method : 'POST',
|
||||
body : text
|
||||
});
|
||||
if (resp.ok){
|
||||
var json = await resp.json();
|
||||
return Object.fromEntries(Object.values(json).map(user => [user.id,user.name]));
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPermissions(){
|
||||
const url = api('task/permissions');
|
||||
const resp = await fetch(url,{credentials: 'include'});
|
||||
@@ -37,7 +52,7 @@
|
||||
|
||||
<table>
|
||||
<tbody>
|
||||
{#each sortedMembers as member,i}
|
||||
{#each sortedMembers as member (member.user.id)}
|
||||
<tr>
|
||||
<td>{member.user.name}</td>
|
||||
<td>
|
||||
|
||||
225
frontend/src/routes/poll/Edit.svelte
Normal file
225
frontend/src/routes/poll/Edit.svelte
Normal file
@@ -0,0 +1,225 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { useTinyRouter } from 'svelte-tiny-router';
|
||||
|
||||
import LineEditor from '../../Components/LineEditor.svelte';
|
||||
import MarkdownEditor from '../../Components/MarkdownEditor.svelte';
|
||||
import Notes from '../notes/RelatedNotes.svelte';
|
||||
import Permissions from '../../Components/PermissionEditor.svelte';
|
||||
|
||||
import { api, get, patch, post } from '../../urls.svelte';
|
||||
import { error, yikes } from '../../warn.svelte';
|
||||
import { t } from '../../translations.svelte';
|
||||
import { user } from '../../user.svelte.js';
|
||||
|
||||
let { id } = $props();
|
||||
let visible_to_guests = false;
|
||||
let new_option = $state({name:'',description:{'source':'',rendered:''}});
|
||||
let new_weight = $state({description:'',weight:0});
|
||||
let poll = $state(null);
|
||||
let members = $state([]);
|
||||
|
||||
function addMember(member){
|
||||
for (let [id,name] of Object.entries(member)) update_permissions({user_id:+id,permission:4});
|
||||
return true;
|
||||
}
|
||||
|
||||
function dropMember(member){
|
||||
let user_id = member.user.id;
|
||||
if (update_permissions({user_id,permission:0})){
|
||||
members = members.filter(m => m.user.id != user_id);
|
||||
}
|
||||
}
|
||||
|
||||
async function load(){
|
||||
let url = api('poll/'+id);
|
||||
let res = await get(url);
|
||||
if (res.ok){
|
||||
poll = await res.json();
|
||||
for (let perm of Object.values(poll.permission)){
|
||||
members.push({ user : { name : perm.name, id : perm.id }, permission: { name : perm.permission.name, code: perm.permission.code}});
|
||||
}
|
||||
visible_to_guests = !poll.private;
|
||||
yikes();
|
||||
} else error(res);
|
||||
}
|
||||
|
||||
async function patch_poll(field, newVal){
|
||||
let url = api(`poll/${id}`);
|
||||
let data = {}
|
||||
data[field] = newVal;
|
||||
let res = await patch(url,data);
|
||||
if (res.ok) {
|
||||
yikes();
|
||||
const json = await res.json();
|
||||
poll = { ...poll, ...json };
|
||||
return true;
|
||||
|
||||
}
|
||||
error(res);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
async function patch_option(option, field, newVal){
|
||||
let url = api(`poll/${id}/option/${option.id}`);
|
||||
let res = await patch(url,{[field]: newVal});
|
||||
if (res.ok) {
|
||||
yikes();
|
||||
const json = await res.json();
|
||||
if (field == 'name' && newVal == ''){
|
||||
poll.options = poll.options.filter(o => o.id !== option.id);
|
||||
} else poll.options = json.options;
|
||||
return true;
|
||||
}
|
||||
error(res);
|
||||
return false;
|
||||
}
|
||||
|
||||
async function patch_weight(data){
|
||||
let url = api(`poll/${id}/weight`);
|
||||
let res = await patch(url,data);
|
||||
if (res.ok) {
|
||||
yikes();
|
||||
const json = await res.json();
|
||||
const weights = json.weights;
|
||||
for (let weight of Object.keys(data)){
|
||||
let desc = data[weight];
|
||||
if (desc) {
|
||||
poll.weights[weight] = desc;
|
||||
} else delete poll.weights[weight]; // TODO: this corrupts the display of the following element!
|
||||
}
|
||||
return true;
|
||||
}
|
||||
error(res);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
async function save_new_option(){
|
||||
if (!new_option.name) return;
|
||||
let url = api('poll/'+id+'/option');
|
||||
let res = await post(url,new_option);
|
||||
if (res.ok){
|
||||
yikes();
|
||||
const json = await res.json();
|
||||
poll.options = json.options;
|
||||
} else error(res);
|
||||
}
|
||||
|
||||
function save_new_weight(e){
|
||||
const data = {};
|
||||
data[new_weight.weight] = new_weight.description;
|
||||
patch_weight(data);
|
||||
}
|
||||
|
||||
function toggle_guest(e){
|
||||
patch_poll('private',!visible_to_guests);
|
||||
}
|
||||
|
||||
async function update_permissions(data){
|
||||
let url = api(`poll/${id}/permissions`);
|
||||
let res = await post(url,data);
|
||||
if (res.ok) {
|
||||
yikes();
|
||||
let json = await res.json();
|
||||
members = members.filter(m => m.user.id != json.user.id);
|
||||
members.push(json);
|
||||
console.log({members});
|
||||
return true;
|
||||
}
|
||||
error(res);
|
||||
return false;
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
|
||||
</script>
|
||||
|
||||
<fieldset>
|
||||
<legend>{t('edit_object',{object:t('poll')})}</legend>
|
||||
{#if poll && poll.name}
|
||||
<fieldset>
|
||||
<legend>{t('name')}</legend>
|
||||
<LineEditor bind:value={poll.name} editable={true} onSet={name => patch_poll('name',name)} />
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{t('description')}</legend>
|
||||
<MarkdownEditor bind:value={poll.description} onSet={desc => patch_poll('description',desc)} />
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{t('permissions')}</legend>
|
||||
<Permissions {addMember} {members} {dropMember} updatePermission={(user_id,perm) => update_permissions({user_id,permission:perm.code})} />
|
||||
<label>
|
||||
<input type="checkbox" bind:checked={visible_to_guests} onchange={toggle_guest} />
|
||||
{t('visible_to_guests')}
|
||||
</label>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{t('options')}</legend>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('name')}</th>
|
||||
<th>{t('description')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each Object.entries(poll.options) as [option_id, option]}
|
||||
<tr>
|
||||
<td>
|
||||
<LineEditor editable={true} value={option.name} onSet={name => patch_option(option,'name',name)} title={t('clear to remove')} />
|
||||
</td>
|
||||
<td>
|
||||
<MarkdownEditor bind:value={option.description} onSet={desc => patch_option(option,'description',desc)} />
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
<tr>
|
||||
<td class="new_option">
|
||||
{t('add_object',{object:t('option')})}
|
||||
<input type="text" bind:value={new_option.name} />
|
||||
</td>
|
||||
<td>
|
||||
<MarkdownEditor simple={true} bind:value={new_option.description} />
|
||||
<button onclick={save_new_option}>{t('save')}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{t('weights')}</legend>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('weight')}</th>
|
||||
<th>{t('description')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each Object.entries(poll.weights) as [weight,descr] (weight)}
|
||||
<tr>
|
||||
<td>
|
||||
<input type="number" value={weight} />
|
||||
</td>
|
||||
<td>
|
||||
<LineEditor editable={true} value={descr} onSet={desc => patch_weight({[weight]: desc})} />
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
<tr>
|
||||
<td>
|
||||
<input type="number" bind:value={new_weight.weight} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" bind:value={new_weight.description} />
|
||||
<button onclick={save_new_weight}>{t('save')}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</fieldset>
|
||||
{/if}
|
||||
</fieldset>
|
||||
<Notes module="poll" entity_id={id} />
|
||||
70
frontend/src/routes/poll/Evaluate.svelte
Normal file
70
frontend/src/routes/poll/Evaluate.svelte
Normal file
@@ -0,0 +1,70 @@
|
||||
<script>
|
||||
import Histogram from '../../Components/Histogram.svelte';
|
||||
import Notes from '../notes/RelatedNotes.svelte';
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import { api, get } from '../../urls.svelte';
|
||||
import { error, yikes } from '../../warn.svelte';
|
||||
import { t } from '../../translations.svelte';
|
||||
let { id } = $props();
|
||||
let poll = $state(null);
|
||||
|
||||
function average(hist){
|
||||
let count = 0;
|
||||
let sum = 0;
|
||||
for (let [k,v] of Object.entries(hist)) {
|
||||
count += v;
|
||||
sum += k*v;
|
||||
}
|
||||
return count > 0 ? sum/count : '?';
|
||||
}
|
||||
|
||||
function max_val(hist){
|
||||
return Math.max(...Object.values(hist));
|
||||
}
|
||||
|
||||
async function load(){
|
||||
let url = api('poll/evaluate/'+id);
|
||||
let res = await get(url);
|
||||
if (res.ok){
|
||||
poll = await res.json();
|
||||
yikes();
|
||||
} else error(res);
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
</script>
|
||||
|
||||
{#if poll}
|
||||
<fieldset>
|
||||
<legend>{poll.name}</legend>
|
||||
<div class="description">{@html poll.description.rendered}</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{t('option')}</td>
|
||||
<td>{t('average')}</td>
|
||||
<td>{t('histogram')}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each Object.entries(poll.evaluation).sort((a,b) => b[0] - a[0]) as [avg,optionset]}
|
||||
{#each Object.entries(optionset) as [option_id,histo]}
|
||||
<tr>
|
||||
<td>
|
||||
{poll.options[option_id].name}
|
||||
</td>
|
||||
<td>
|
||||
{(+avg).toFixed(3)}
|
||||
</td>
|
||||
<td>
|
||||
<Histogram data={histo} />
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</fieldset>
|
||||
{/if}
|
||||
<Notes module="poll" entity_id={id} />
|
||||
88
frontend/src/routes/poll/Index.svelte
Normal file
88
frontend/src/routes/poll/Index.svelte
Normal file
@@ -0,0 +1,88 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import LineEditor from '../../Components/LineEditor.svelte';
|
||||
|
||||
import { useTinyRouter } from 'svelte-tiny-router';
|
||||
import { api, get, post } from '../../urls.svelte';
|
||||
import { error, yikes } from '../../warn.svelte';
|
||||
import { t } from '../../translations.svelte';
|
||||
import { user } from '../../user.svelte.js';
|
||||
|
||||
let polls = $state([]);
|
||||
let router = useTinyRouter();
|
||||
|
||||
async function create_poll(name){
|
||||
const url = api('poll');
|
||||
const res = await post(url,{name});
|
||||
if (res.ok) {
|
||||
yikes();
|
||||
const json = await res.json();
|
||||
polls.push(json);
|
||||
}
|
||||
error(res);
|
||||
}
|
||||
|
||||
function edit(poll){
|
||||
router.navigate(`/poll/${poll.id}/edit`);
|
||||
}
|
||||
|
||||
function evaluate(poll){
|
||||
router.navigate(`/poll/${poll.id}/evaluate`);
|
||||
}
|
||||
|
||||
async function load(){
|
||||
let url = api('poll/list');
|
||||
let res = await get(url);
|
||||
if (res.ok){
|
||||
polls = await res.json();
|
||||
yikes();
|
||||
} else error(res);
|
||||
}
|
||||
|
||||
function open(poll){
|
||||
router.navigate(`/poll/${poll.id}/view`);
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
|
||||
</script>
|
||||
|
||||
<fieldset>
|
||||
<legend>{t('polls')}</legend>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('name')}</th>
|
||||
<th>{t('description')}</th>
|
||||
<th>{t('owner')}</th>
|
||||
<th>{t('actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each polls as poll}
|
||||
<tr>
|
||||
<td onclick={e => open(poll)}>{poll.name}</td>
|
||||
<td>{@html poll.description.rendered}</td>
|
||||
<td onclick={e => open(poll)}>{poll.owner.name}</td>
|
||||
<td>
|
||||
{#if user.id}
|
||||
{#if user.id == poll.owner.id || (poll.permission[user.id] && poll.permission[user.id].permission.code == 2)}
|
||||
<button onclick={e => edit(poll)}>{t('edit')}</button>
|
||||
{/if}
|
||||
{#if user.id == poll.owner.id || (poll.permission[user.id] && poll.permission[user.id].permission.code > 0)}
|
||||
<button onclick={e => evaluate(poll)}>{t('evaluate')}</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
<tr>
|
||||
<td>
|
||||
<LineEditor simple={true} onSet={create_poll}/>
|
||||
</td>
|
||||
<td colspan="3">{t('Enter name to create new')}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</fieldset>
|
||||
105
frontend/src/routes/poll/View.svelte
Normal file
105
frontend/src/routes/poll/View.svelte
Normal file
@@ -0,0 +1,105 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { api, get, post } from '../../urls.svelte';
|
||||
import { error, yikes } from '../../warn.svelte';
|
||||
import { user } from '../../user.svelte';
|
||||
import Notes from '../notes/RelatedNotes.svelte';
|
||||
|
||||
let { id } = $props();
|
||||
import { t } from '../../translations.svelte';
|
||||
let poll = $state(null);
|
||||
let selection = $state({});
|
||||
let editor = $state(user ? { name: user.name, user_id : user.id } : { name : '', user_id : -1 })
|
||||
let disabled = $state(false);
|
||||
|
||||
async function load(){
|
||||
let url = api('poll/'+id);
|
||||
let res = await get(url);
|
||||
if (res.ok){
|
||||
poll = await res.json();
|
||||
if (poll.selection) selection = poll.selection;
|
||||
yikes();
|
||||
} else error(res);
|
||||
}
|
||||
|
||||
async function save(ev){
|
||||
disabled = true;
|
||||
let url = api(`poll/${id}/select`);
|
||||
let res = await post(url,{editor,selection});
|
||||
if (res.ok) {
|
||||
yikes();
|
||||
} else error(res);
|
||||
}
|
||||
|
||||
function select(option,weight){
|
||||
disabled = false;
|
||||
selection[option.id] = +weight;
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
table td:nth-child(n+2) {
|
||||
text-align: center;
|
||||
}
|
||||
.radio {
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
|
||||
<fieldset>
|
||||
<legend>{t('User')}</legend>
|
||||
{#if user.name}
|
||||
<div>{t('logged in as: {user}',{user:user.name})}</div>
|
||||
{:else}
|
||||
<label>
|
||||
{t('Your name')}
|
||||
<input type="text" bind:value={editor.name} />
|
||||
</label>
|
||||
{/if}
|
||||
</fieldset>
|
||||
{#if poll}
|
||||
<fieldset>
|
||||
<legend>{t('poll')}: {poll.name}</legend>
|
||||
<div class="description">
|
||||
{@html poll.description.rendered}
|
||||
</div>
|
||||
<table class="poll">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{t('option')}</td>
|
||||
{#each Object.entries(poll.weights).sort((a,b) => a[0] - b[0]) as [weight,name]}
|
||||
<td class="weight">
|
||||
{weight}
|
||||
<span class="description">
|
||||
{name}
|
||||
</span>
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each Object.entries(poll.options) as [option_id,option]}
|
||||
<tr>
|
||||
<td class="option">
|
||||
{option.name}
|
||||
<span class="description">
|
||||
{@html option.description.rendered}
|
||||
</span>
|
||||
</td>
|
||||
{#each Object.entries(poll.weights) as [weight,name]}
|
||||
<td class="radio" onclick={e => select(option,weight)} title={t('click to select')} >
|
||||
{#if selection[option_id] == weight}
|
||||
X
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<button onclick={save} disabled={disabled || !editor.name || !Object.keys(selection).length}>{t('save')} </button>
|
||||
</fieldset>
|
||||
{/if}
|
||||
<Notes module="poll" entity_id={id} />
|
||||
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { useTinyRouter } from 'svelte-tiny-router';
|
||||
import { api, drop, get, patch } from '../../urls.svelte';
|
||||
import { error, yikes } from '../../warn.svelte';
|
||||
import { t } from '../../translations.svelte';
|
||||
@@ -17,8 +18,9 @@
|
||||
let location = $state(null);
|
||||
let draggedItem = $state(null)
|
||||
let draggedLocation = $state(null)
|
||||
let { item_id, location_id, owner, owner_id } = $props();
|
||||
let { item_id, location_id, owner, owner_id, owner_number } = $props();
|
||||
let skip_location = false; // disable effect on setting location within loadItem()
|
||||
let router = useTinyRouter();
|
||||
|
||||
$effect(() => {
|
||||
// This effect runs whenever `location` changes
|
||||
@@ -98,8 +100,8 @@
|
||||
}
|
||||
|
||||
async function loadItem(){
|
||||
if (!item_id) return;
|
||||
const url = api(`stock/${owner}/${owner_id}/item/${item_id}`);
|
||||
if (!owner_number) return;
|
||||
const url = api(`stock/${owner}/${owner_id}/item/${owner_number}`);
|
||||
const res = await get(url);
|
||||
if (res.ok){
|
||||
yikes();
|
||||
@@ -116,7 +118,7 @@
|
||||
}
|
||||
}
|
||||
for (let i of json.items){
|
||||
if (i.owner_number == +item_id) item = i;
|
||||
if (i.owner_number == +owner_number) item = i;
|
||||
}
|
||||
} else {
|
||||
error(res);
|
||||
@@ -170,12 +172,27 @@
|
||||
}
|
||||
|
||||
async function load(){
|
||||
await preload();
|
||||
await loadUserLocations();
|
||||
await loadPath();
|
||||
await loadProperties();
|
||||
await loadItem();
|
||||
}
|
||||
|
||||
async function preload(){
|
||||
if (item_id) {
|
||||
let url = api(`stock/item/${item_id}`);
|
||||
const res = await get(url);
|
||||
if (res.ok){
|
||||
const json = await res.json();
|
||||
owner = json.owner.type;
|
||||
owner_id = json.owner.id;
|
||||
owner_number = json.owner_number;
|
||||
location_id = json.location.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function moveToTop(loc){
|
||||
if (patchLocation(location,'parent_location_id',0)){
|
||||
loc.parent_location_id = 0;
|
||||
|
||||
@@ -75,6 +75,10 @@
|
||||
</div>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{t('ID')}</td>
|
||||
<td>{item.id}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t('Code')}:</td>
|
||||
<td>
|
||||
|
||||
6
poll/build.gradle.kts
Normal file
6
poll/build.gradle.kts
Normal file
@@ -0,0 +1,6 @@
|
||||
description = "Umbrella : Polls"
|
||||
|
||||
dependencies{
|
||||
implementation(project(":core"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/* © SRSoftware 2025 */
|
||||
package de.srsoftware.umbrella.poll;
|
||||
|
||||
public class Constants {
|
||||
public static final String CONFIG_DATABASE = "umbrella.modules.poll.database";
|
||||
public static final String TABLE_OPTIONS = "options";
|
||||
public static final String TABLE_POLLS = "polls";
|
||||
public static final String TABLE_SELECTIONS = "selections";
|
||||
public static final String TABLE_SHARES = "shares";
|
||||
public static final String TABLE_WEIGHTS = "weights";
|
||||
}
|
||||
26
poll/src/main/java/de/srsoftware/umbrella/poll/PollDb.java
Normal file
26
poll/src/main/java/de/srsoftware/umbrella/poll/PollDb.java
Normal file
@@ -0,0 +1,26 @@
|
||||
/* © SRSoftware 2025 */
|
||||
package de.srsoftware.umbrella.poll;
|
||||
|
||||
import de.srsoftware.umbrella.core.model.Permission;
|
||||
import de.srsoftware.umbrella.core.model.Poll;
|
||||
import de.srsoftware.umbrella.core.model.UmbrellaUser;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Map;
|
||||
|
||||
public interface PollDb {
|
||||
Collection<Poll> listPolls(UmbrellaUser user);
|
||||
|
||||
Poll.Evaluation loadEvaluation(Poll poll);
|
||||
|
||||
Poll loadPoll(String id);
|
||||
|
||||
Map<Integer,Integer> loadSelections(Poll poll, UmbrellaUser user);
|
||||
|
||||
Poll save(Poll poll);
|
||||
void saveSelection(Poll poll, Map<Integer,Integer> optionsToWeights, String guestName);
|
||||
|
||||
void saveSelection(Poll poll, Map<Integer,Integer> optionsToWeights, UmbrellaUser user);
|
||||
|
||||
void setPermission(String pollId, UmbrellaUser userId, Permission permission);
|
||||
}
|
||||
283
poll/src/main/java/de/srsoftware/umbrella/poll/PollModule.java
Normal file
283
poll/src/main/java/de/srsoftware/umbrella/poll/PollModule.java
Normal file
@@ -0,0 +1,283 @@
|
||||
/* © SRSoftware 2025 */
|
||||
package de.srsoftware.umbrella.poll;
|
||||
|
||||
import static de.srsoftware.umbrella.core.ConnectionProvider.connect;
|
||||
import static de.srsoftware.umbrella.core.ModuleRegistry.userService;
|
||||
import static de.srsoftware.umbrella.core.constants.Field.*;
|
||||
import static de.srsoftware.umbrella.core.constants.Path.*;
|
||||
import static de.srsoftware.umbrella.core.constants.Path.WEIGHT;
|
||||
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.*;
|
||||
import static de.srsoftware.umbrella.poll.Constants.CONFIG_DATABASE;
|
||||
import static java.lang.System.Logger.Level.WARNING;
|
||||
import static java.net.HttpURLConnection.HTTP_OK;
|
||||
import static java.text.MessageFormat.format;
|
||||
import static de.srsoftware.umbrella.core.model.Permission.READ_ONLY;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import de.srsoftware.configuration.Configuration;
|
||||
import de.srsoftware.tools.Path;
|
||||
import de.srsoftware.tools.SessionToken;
|
||||
import de.srsoftware.umbrella.core.BaseHandler;
|
||||
import de.srsoftware.umbrella.core.ModuleRegistry;
|
||||
import de.srsoftware.umbrella.core.api.PollService;
|
||||
import de.srsoftware.umbrella.core.constants.Field;
|
||||
import de.srsoftware.umbrella.core.constants.Text;
|
||||
import de.srsoftware.umbrella.core.exceptions.UmbrellaException;
|
||||
import de.srsoftware.umbrella.core.model.Permission;
|
||||
import de.srsoftware.umbrella.core.model.Poll;
|
||||
import de.srsoftware.umbrella.core.model.Token;
|
||||
import de.srsoftware.umbrella.core.model.UmbrellaUser;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.*;
|
||||
|
||||
public class PollModule extends BaseHandler implements PollService {
|
||||
|
||||
private PollDb pollDb;
|
||||
|
||||
public PollModule(Configuration config){
|
||||
super();
|
||||
var dbFile = config.get(CONFIG_DATABASE).orElseThrow(() -> missingField(CONFIG_DATABASE));
|
||||
pollDb = new SqliteDb(connect(dbFile));
|
||||
ModuleRegistry.add(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean doGet(Path path, HttpExchange ex) throws IOException {
|
||||
addCors(ex);
|
||||
try {
|
||||
Optional<Token> token = SessionToken.from(ex).map(Token::of);
|
||||
var head = path.pop();
|
||||
var user = userService().loadUser(token).orElse(null);
|
||||
return switch (head) {
|
||||
case EVALUATE -> getPollEvaluation(ex,user, path);
|
||||
case LIST -> getPollList(ex,user);
|
||||
case null -> super.doGet(path,ex);
|
||||
default -> getPoll(ex,user,head);
|
||||
};
|
||||
} catch (UmbrellaException e){
|
||||
return send(ex,e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean doPatch(Path path, HttpExchange ex) throws IOException {
|
||||
addCors(ex);
|
||||
try {
|
||||
Optional<Token> token = SessionToken.from(ex).map(Token::of);
|
||||
var user = userService().loadUser(token);
|
||||
if (user.isEmpty()) return unauthorized(ex);
|
||||
var head = path.pop();
|
||||
return switch (head) {
|
||||
case null -> super.doPatch(path,ex);
|
||||
default -> patchPoll(ex,user.get(),head, path);
|
||||
};
|
||||
} catch (UmbrellaException e){
|
||||
return send(ex,e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean doPost(Path path, HttpExchange ex) throws IOException {
|
||||
addCors(ex);
|
||||
try {
|
||||
Optional<Token> token = SessionToken.from(ex).map(Token::of);
|
||||
var user = userService().loadUser(token).orElse(null );
|
||||
var head = path.pop();
|
||||
return switch (head) {
|
||||
case null -> postNewPoll(ex,user);
|
||||
default -> postToPoll(ex, user, head, path);
|
||||
};
|
||||
} catch (UmbrellaException e){
|
||||
return send(ex,e);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean getPoll(HttpExchange ex, UmbrellaUser user, String pollId) throws IOException {
|
||||
var poll = pollDb.loadPoll(pollId);
|
||||
var permitted = !poll.isPrivate() || poll.owner().equals(user);
|
||||
if (!permitted && poll.permissions().get(user) == null) throw forbidden(Text.NOT_ALLOWED_TO_EDIT, Field.OBJECT,Text.POLL);
|
||||
var result = new HashMap<>(poll.toMap());
|
||||
if (user != null) result.put(SELECTION,pollDb.loadSelections(poll, user));
|
||||
|
||||
return sendContent(ex,result);
|
||||
}
|
||||
|
||||
private boolean getPollEvaluation(HttpExchange ex, UmbrellaUser user, Path path) throws IOException {
|
||||
if (user == null) return unauthorized(ex);
|
||||
if (path.empty()) throw missingField(ID);
|
||||
var poll = pollDb.loadPoll(path.pop());
|
||||
if (!poll.owner().equals(user)) {
|
||||
switch (poll.permissions().get(user)) {
|
||||
case Permission.EDIT:
|
||||
case Permission.OWNER:
|
||||
break;
|
||||
case null, default:
|
||||
throw forbidden(Text.NOT_ALLOWED_TO_EVALUATE, Field.OBJECT, Text.POLL);
|
||||
}
|
||||
}
|
||||
var result = new HashMap<>(poll.toMap());
|
||||
var evaluation = pollDb.loadEvaluation(poll);
|
||||
result.put(Field.EVALUATION,evaluation.toMap());
|
||||
return sendContent(ex,result);
|
||||
}
|
||||
|
||||
private boolean getPollList(HttpExchange ex, UmbrellaUser user) throws IOException {
|
||||
if (user == null) return unauthorized(ex);
|
||||
var list = pollDb.listPolls(user).stream().map(Poll::toMap);
|
||||
return sendContent(ex,list);
|
||||
}
|
||||
|
||||
private boolean patchPoll(HttpExchange ex, UmbrellaUser user, String pollId, Path path) throws IOException {
|
||||
var poll = pollDb.loadPoll(pollId);
|
||||
var permitted = poll.owner().equals(user);
|
||||
if (!permitted && !Set.of(Permission.EDIT, Permission.OWNER).contains(poll.permissions().get(user))) throw forbidden(Text.NOT_ALLOWED_TO_EDIT, Field.OBJECT,Text.POLL);
|
||||
|
||||
var head = path.pop();
|
||||
return switch (head){
|
||||
case null -> patchPoll(ex, poll);
|
||||
case OPTION -> patchPollOptions(ex, path, poll);
|
||||
case WEIGHT -> patchWeights(ex, poll);
|
||||
default -> notFound(ex);
|
||||
};
|
||||
}
|
||||
|
||||
private boolean patchPoll(HttpExchange ex, Poll poll) throws IOException {
|
||||
var json = json(ex);
|
||||
for (var key : json.keySet()){
|
||||
switch (key){
|
||||
case Field.DESCRIPTION:
|
||||
poll.description(json.getString(key)); break;
|
||||
case Field.NAME:
|
||||
poll.name(json.getString(key)); break;
|
||||
case PRIVATE:
|
||||
poll.setPrivate(json.getBoolean(key)); break;
|
||||
case null, default:
|
||||
throw UmbrellaException.badRequest(Text.UNKNOWN_FIELD,ID,key);
|
||||
}
|
||||
}
|
||||
return sendContent(ex,pollDb.save(poll));
|
||||
}
|
||||
|
||||
private boolean patchWeights(HttpExchange ex, Poll poll) throws IOException {
|
||||
var json = json(ex);
|
||||
for (var key : json.keySet()) try {
|
||||
var weight = Integer.parseInt(key);
|
||||
if (!(json.get(key) instanceof String description)) throw invalidField(Field.DESCRIPTION,Text.STRING);
|
||||
poll.setWeight(weight,description);
|
||||
} catch (NumberFormatException nfe) {
|
||||
throw invalidField(Text.WEIGHT,Text.NUMBER);
|
||||
}
|
||||
return sendContent(ex,pollDb.save(poll));
|
||||
}
|
||||
|
||||
private boolean patchPollOptions(HttpExchange ex, Path path, Poll poll) throws IOException {
|
||||
try {
|
||||
if (path.empty()) throw missingField(ID);
|
||||
var optionId = Integer.parseInt(path.pop());
|
||||
var option = poll.options().stream().filter(o -> o.id()==optionId).findFirst().orElse(null);
|
||||
if (option == null) throw failedToLoadObject(Text.OPTION);
|
||||
var json = json(ex);
|
||||
for (var key : json.keySet()){
|
||||
switch (key) {
|
||||
case ID:
|
||||
break;
|
||||
case Field.NAME:
|
||||
option.name(json.getString(key)); break;
|
||||
case Field.DESCRIPTION:
|
||||
option.description(json.getString(key)); break;
|
||||
default:
|
||||
throw UmbrellaException.badRequest(Text.UNKNOWN_FIELD,FIELD,key);
|
||||
}
|
||||
}
|
||||
|
||||
return sendContent(ex, pollDb.save(poll));
|
||||
} catch (NumberFormatException nfe){
|
||||
throw invalidField(ID,Text.NUMBER).causedBy(nfe);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean postNewPoll(HttpExchange ex, UmbrellaUser user) throws IOException {
|
||||
if (user == null) return unauthorized(ex);
|
||||
var json = json(ex);
|
||||
if (!json.has(Field.NAME)) throw missingField(Field.NAME);
|
||||
var name = json.getString(Field.NAME);
|
||||
var poll = new Poll(null,user,name,"",true, List.of(), Map.of());
|
||||
return sendContent(ex,pollDb.save(poll));
|
||||
}
|
||||
|
||||
private boolean postToPoll(HttpExchange ex, UmbrellaUser user, String id, Path path) throws IOException {
|
||||
var head = path.pop();
|
||||
var poll = pollDb.loadPoll(id);
|
||||
if (SELECT.equals(head)) {
|
||||
if (user == null && poll.isPrivate()) return unauthorized(ex);
|
||||
return postSelection(ex, poll, user);
|
||||
}
|
||||
var permitted = poll.owner().equals(user);
|
||||
if (!permitted && !Set.of(Permission.OWNER, Permission.EDIT).contains(poll.permissions().get(user))) throw forbidden(Text.NOT_ALLOWED_TO_EDIT, Field.OBJECT,Text.POLL);
|
||||
return switch (head){
|
||||
case PERMISSIONS -> postPermission(ex, poll, user);
|
||||
case OPTION -> postOption(ex, poll);
|
||||
case null, default -> notFound(ex);
|
||||
};
|
||||
}
|
||||
|
||||
private boolean postPermission(HttpExchange ex, Poll poll, UmbrellaUser user) throws IOException {
|
||||
LOG.log(WARNING,"Permission check not implemented for postPermission");
|
||||
|
||||
var json = json(ex);
|
||||
if (!json.has(USER_ID)) throw missingField(USER_ID);
|
||||
if (!json.has(PERMISSION)) throw missingField(PERMISSION);
|
||||
if (!(json.get(USER_ID) instanceof Number userId)) throw invalidField(USER_ID,NUMBER);
|
||||
if (!(json.get(PERMISSION) instanceof Number perm)) throw invalidField(PERMISSION,NUMBER);
|
||||
var modifiedUser = userService().loadUser(userId.longValue());
|
||||
if (perm.intValue() == 0){
|
||||
pollDb.setPermission(poll.id(), modifiedUser, null);
|
||||
return sendEmptyResponse(HTTP_OK,ex);
|
||||
}
|
||||
var permission = Permission.of(perm.intValue());
|
||||
if (permission == Permission.ASSIGNEE) permission = Permission.EDIT;
|
||||
pollDb.setPermission(poll.id(), modifiedUser, permission);
|
||||
return sendContent(ex,Map.of(
|
||||
Field.USER,modifiedUser.toMap(),
|
||||
PERMISSION, permission.toMap()
|
||||
));
|
||||
}
|
||||
|
||||
private boolean postSelection(HttpExchange ex, Poll poll, UmbrellaUser user) throws IOException {
|
||||
var json = json(ex);
|
||||
if (!json.has(Field.SELECTION)) throw missingField(Field.SELECTION);
|
||||
if (!(json.get(Field.SELECTION) instanceof JSONObject job)) throw invalidField(Field.SELECTION,JSON);
|
||||
var map = new HashMap<Integer,Integer>();
|
||||
for (var key : job.keySet()){
|
||||
var optionId = Integer.parseInt(key);
|
||||
if (!(job.get(key) instanceof Integer weight)) throw invalidField(Field.WEIGHT,Text.NUMBER);
|
||||
map.put(optionId,weight);
|
||||
}
|
||||
if (user == null) {
|
||||
if (!json.has(Field.EDITOR)) throw missingField(Field.EDITOR);
|
||||
if (!(json.get(Field.EDITOR) instanceof JSONObject editor)) throw invalidField(Field.EDITOR,JSON);
|
||||
if (!editor.has(Field.NAME)) throw missingField(format("{0}.{1}}",Field.EDITOR,Field.NAME));
|
||||
if (!(editor.get(Field.NAME) instanceof String name)) throw invalidField(format("{0}.{1}",Field.EDITOR,Field.NAME),Text.STRING);
|
||||
pollDb.saveSelection(poll, map, name);
|
||||
} else pollDb.saveSelection(poll, map, user);
|
||||
return sendContent(ex,poll);
|
||||
}
|
||||
|
||||
private boolean postOption(HttpExchange ex, Poll poll) throws IOException {
|
||||
var json = json(ex);
|
||||
if (!json.has(Field.NAME) || !(json.get(Field.NAME) instanceof String name)) throw missingField(Field.NAME);
|
||||
String description = null;
|
||||
if (json.has(Field.DESCRIPTION)) {
|
||||
var val = json.get(Field.DESCRIPTION);
|
||||
if (val instanceof JSONObject j && j.has(Field.SOURCE)) val = j.get(Field.SOURCE);
|
||||
if (val instanceof String d) description = d;
|
||||
}
|
||||
var options = poll.options();
|
||||
int newId = options.stream().map(Poll.Option::id).max(Integer::compareTo).orElse(0) + 1;
|
||||
|
||||
var option = new Poll.Option(0, name, description, 0).id(newId);
|
||||
poll.options().add(option);
|
||||
return sendContent(ex,pollDb.save(poll));
|
||||
}
|
||||
}
|
||||
364
poll/src/main/java/de/srsoftware/umbrella/poll/SqliteDb.java
Normal file
364
poll/src/main/java/de/srsoftware/umbrella/poll/SqliteDb.java
Normal file
@@ -0,0 +1,364 @@
|
||||
/* © SRSoftware 2025 */
|
||||
package de.srsoftware.umbrella.poll;
|
||||
|
||||
import static de.srsoftware.tools.Optionals.is0;
|
||||
import static de.srsoftware.tools.jdbc.Condition.equal;
|
||||
import static de.srsoftware.tools.jdbc.Query.*;
|
||||
import static de.srsoftware.tools.jdbc.Query.SelectQuery.ALL;
|
||||
import static de.srsoftware.umbrella.core.ModuleRegistry.userService;
|
||||
import static de.srsoftware.umbrella.core.constants.Field.*;
|
||||
import static de.srsoftware.umbrella.core.constants.Field.DESCRIPTION;
|
||||
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.*;
|
||||
import static de.srsoftware.umbrella.core.model.Permission.READ_ONLY;
|
||||
import static de.srsoftware.umbrella.poll.Constants.*;
|
||||
import static java.text.MessageFormat.format;
|
||||
|
||||
import de.srsoftware.tools.jdbc.Query;
|
||||
import de.srsoftware.umbrella.core.BaseDb;
|
||||
import de.srsoftware.umbrella.core.constants.Field;
|
||||
import de.srsoftware.umbrella.core.constants.Text;
|
||||
import de.srsoftware.umbrella.core.exceptions.UmbrellaException;
|
||||
import de.srsoftware.umbrella.core.model.Permission;
|
||||
import de.srsoftware.umbrella.core.model.Poll;
|
||||
import de.srsoftware.umbrella.core.model.UmbrellaUser;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.util.*;
|
||||
|
||||
public class SqliteDb extends BaseDb implements PollDb {
|
||||
|
||||
public SqliteDb(Connection connection) {
|
||||
super(connection);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int createTables() {
|
||||
var version = createSettingsTable();
|
||||
switch (version){
|
||||
case 0:
|
||||
createPollsTable();
|
||||
createOptionsTable();
|
||||
createWeightsTable();
|
||||
createSelectionsTable();
|
||||
case 1:
|
||||
createSharesTable();
|
||||
case 2:
|
||||
updateSharesTable();
|
||||
}
|
||||
return setCurrentVersion(3);
|
||||
}
|
||||
|
||||
private void createOptionsTable() {
|
||||
var sql = "CREATE TABLE IF NOT EXISTS {0} ({1} INT NOT NULL, {2} VARCHAR(255) NOT NULL REFERENCES {3}({1}), {4} VARCHAR(255) NOT NULL, {5} TEXT, {6} INT DEFAULT 0, PRIMARY KEY ({1}, {2}))";
|
||||
try {
|
||||
var stmt = db.prepareStatement(format(sql,TABLE_OPTIONS, Field.ID,Field.POLL_ID,TABLE_POLLS,Field.NAME, Field.DESCRIPTION, Field.STATUS));
|
||||
stmt.execute();
|
||||
stmt.close();
|
||||
} catch (SQLException e) {
|
||||
throw failedToCreateTable(TABLE_OPTIONS).causedBy(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void createPollsTable() {
|
||||
var sql = "CREATE TABLE IF NOT EXISTS {0} ({1} VARCHAR(255) NOT NULL PRIMARY KEY, {2} INT NOT NULL, {3} VARCHAR(255) NOT NULL, {4} TEXT, {5} BOOLEAN)";
|
||||
try {
|
||||
var stmt = db.prepareStatement(format(sql,TABLE_POLLS, Field.ID,Field.USER_ID,Field.NAME,Field.DESCRIPTION, Field.PRIVATE));
|
||||
stmt.execute();
|
||||
stmt.close();
|
||||
} catch (SQLException e) {
|
||||
throw failedToCreateTable(TABLE_POLLS).causedBy(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void createSelectionsTable() {
|
||||
var sql = """
|
||||
CREATE TABLE IF NOT EXISTS {0} (
|
||||
{1} VARCHAR(255) NOT NULL,
|
||||
{2} INT NOT NULL,
|
||||
{3} INT NOT NULL,
|
||||
{4} INT NOT NULL,
|
||||
FOREIGN KEY ({1}) REFERENCES {5}({7}),
|
||||
FOREIGN KEY ({1},{2}) REFERENCES {6}({1}, {7}),
|
||||
FOREIGN KEY ({1}, {4}) REFERENCES {8}({1}, {4}),
|
||||
PRIMARY KEY ({1}, {2}, {3}))""";
|
||||
try {
|
||||
var stmt = db.prepareStatement(format(sql,
|
||||
TABLE_SELECTIONS,
|
||||
POLL_ID, OPTION_ID, USER_ID, Field.WEIGHT,
|
||||
TABLE_POLLS, TABLE_OPTIONS, ID, TABLE_WEIGHTS));
|
||||
stmt.execute();
|
||||
stmt.close();
|
||||
} catch (SQLException e) {
|
||||
throw failedToCreateTable(TABLE_SELECTIONS).causedBy(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void createSharesTable() {
|
||||
var sql = "CREATE TABLE IF NOT EXISTS {0} ({1} VARCHAR(255) NOT NULL REFERENCES {2}({3}), {4} INT NOT NULL, {5} INT, PRIMARY KEY ({1}, {4}))";
|
||||
try {
|
||||
var stmt = db.prepareStatement(format(sql,TABLE_SHARES,POLL_ID,TABLE_POLLS,ID,USER_ID, PERMISSION));
|
||||
stmt.execute();
|
||||
stmt.close();
|
||||
} catch (SQLException e) {
|
||||
throw failedToCreateTable(TABLE_SHARES).causedBy(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void createWeightsTable(){
|
||||
var sql = "CREATE TABLE IF NOT EXISTS {0} ( {1} INT NOT NULL, {2} VARCHAR(255) NOT NULL REFERENCES {3}({4}), {5} TEXT, PRIMARY KEY ({2}, {1}))";
|
||||
try {
|
||||
var stmt = db.prepareStatement(format(sql,TABLE_WEIGHTS, WEIGHT,POLL_ID,TABLE_POLLS, ID, DESCRIPTION));
|
||||
stmt.execute();
|
||||
stmt.close();
|
||||
} catch (SQLException e) {
|
||||
throw failedToCreateTable(TABLE_WEIGHTS).causedBy(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void dropWeight(Poll poll, int weight){
|
||||
try {
|
||||
delete().from(TABLE_WEIGHTS).where(POLL_ID, equal(poll.id())).where(WEIGHT, equal(weight)).execute(db);
|
||||
poll.weights().remove(weight);
|
||||
} catch (SQLException e){
|
||||
throw failedToDropObject(Text.WEIGHT);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Poll> listPolls(UmbrellaUser user) {
|
||||
try {
|
||||
var sql = "SELECT DISTINCT {0}.*, {5} FROM {0} LEFT JOIN {1} ON {0}.{2} = {1}.{4} WHERE {0}.{7} IS FALSE OR {0}.{3} = ? OR {1}.{3} = ? ORDER BY {6} ASC;";
|
||||
var ps = db.prepareStatement(format(sql,TABLE_POLLS,TABLE_SHARES, ID, USER_ID, POLL_ID, PERMISSION, NAME, PRIVATE));
|
||||
ps.setLong(1,user.id());
|
||||
ps.setLong(2, user.id());
|
||||
var rs = ps.executeQuery();
|
||||
var map = new HashMap<String,Poll>();
|
||||
while (rs.next()) {
|
||||
var pollId = rs.getString(ID);
|
||||
if (map.containsKey(pollId)) continue;
|
||||
var poll = Poll.of(rs);
|
||||
var perm = rs.getInt(PERMISSION);
|
||||
if (perm != 0) poll.permissions().put(user,Permission.of(perm));
|
||||
map.put(pollId,poll);
|
||||
}
|
||||
rs.close();
|
||||
return map.values().stream().sorted(Comparator.comparing(Poll::name)).toList();
|
||||
} catch (SQLException sqle){
|
||||
throw failedToLoadObject(TABLE_POLLS);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Poll.Evaluation loadEvaluation(Poll poll) {
|
||||
try {
|
||||
var result = new Poll.Evaluation(poll);
|
||||
var rs = select(ALL).from(TABLE_SELECTIONS).where(POLL_ID,equal(poll.id())).exec(db);
|
||||
while (rs.next()) result.count(rs);
|
||||
rs.close();
|
||||
return result;
|
||||
} catch (SQLException e) {
|
||||
throw failedToLoadObject(Text.EVALUATION);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Poll loadPoll(String id) {
|
||||
try {
|
||||
var rs = select(ALL).from(TABLE_POLLS).where(ID,equal(id)).exec(db);
|
||||
Poll poll = null;
|
||||
if (rs.next()) poll = Poll.of(rs);
|
||||
rs.close();
|
||||
if (poll == null) throw failedToLoadObject(Text.POLL,id);
|
||||
|
||||
rs = select(ALL).from(TABLE_OPTIONS).where(POLL_ID,equal(id)).exec(db);
|
||||
while (rs.next()) poll.options().add(Poll.Option.of(rs));
|
||||
rs.close();
|
||||
|
||||
rs = select(ALL).from(TABLE_WEIGHTS).where(POLL_ID,equal(id)).sort(WEIGHT+" ASC").exec(db);
|
||||
while (rs.next()) {
|
||||
var weight = rs.getInt(WEIGHT);
|
||||
var descr = rs.getString(DESCRIPTION);
|
||||
poll.weights().put(weight,descr);
|
||||
}
|
||||
rs.close();
|
||||
|
||||
rs = select(ALL).from(TABLE_SHARES).where(POLL_ID,equal(id)).exec(db);
|
||||
var permissions = new HashMap<Long,Permission>();
|
||||
while (rs.next()) permissions.put(rs.getLong(USER_ID),Permission.of(rs.getInt(PERMISSION)));
|
||||
rs.close();
|
||||
if (!permissions.isEmpty()) {
|
||||
var users = userService().list(null, null, permissions.keySet());
|
||||
for (var entry : users.entrySet()) poll.permissions().put(entry.getValue(), permissions.get(entry.getKey()));
|
||||
}
|
||||
return poll;
|
||||
} catch (SQLException e) {
|
||||
throw failedToLoadObject(Text.POLL,id).causedBy(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<Integer, Integer> loadSelections(Poll poll, UmbrellaUser user) {
|
||||
try {
|
||||
var map = new HashMap<Integer,Integer>();
|
||||
var rs = select(ALL).from(TABLE_SELECTIONS).where(POLL_ID, equal(poll.id())).where(USER, equal(user.id())).exec(db);
|
||||
while (rs.next()) map.put(rs.getInt(OPTION_ID),rs.getInt(WEIGHT));
|
||||
rs.close();
|
||||
return map;
|
||||
} catch (SQLException e) {
|
||||
throw failedToLoadObject(Text.SELECTIONS);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public Poll save(Poll poll) {
|
||||
return is0(poll.id()) ? saveNew(poll) : update(poll);
|
||||
}
|
||||
|
||||
public Poll.Option saveOption(String pollId, Poll.Option option) {
|
||||
try {
|
||||
if (option.isNew()) return saveNew(pollId,option);
|
||||
if (option.isDirty()) return update(pollId, option);
|
||||
return option;
|
||||
} catch (SQLException e){
|
||||
throw failedToStoreObject(Text.OPTION);
|
||||
}
|
||||
}
|
||||
|
||||
private Poll saveNew(Poll poll) {
|
||||
var uuid = UUID.randomUUID().toString();
|
||||
try {
|
||||
insertInto(TABLE_POLLS,Field.ID, USER_ID, NAME, DESCRIPTION, PRIVATE)
|
||||
.values(uuid,poll.owner().id(),poll.name(),poll.description(),poll.isPrivate())
|
||||
.execute(db).close();
|
||||
} catch (SQLException e) {
|
||||
throw failedToStoreObject(poll);
|
||||
}
|
||||
return new Poll(uuid,poll.owner(),poll.name(),poll.description(),poll.isPrivate(), List.of(), Map.of());
|
||||
}
|
||||
|
||||
private Poll.Option saveNew(String pollId, Poll.Option option) throws SQLException {
|
||||
insertInto(TABLE_OPTIONS, ID, POLL_ID, NAME, DESCRIPTION, STATUS)
|
||||
.values(option.id(), pollId, option.name(), option.description(), option.status())
|
||||
.execute(db).close();
|
||||
return option;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveSelection(Poll poll, Map<Integer, Integer> optionsToWeights, String guestName) {
|
||||
saveSelection(poll.id(), optionsToWeights, guestName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveSelection(Poll poll, Map<Integer, Integer> optionsToWeights, UmbrellaUser user) {
|
||||
saveSelection(poll.id(), optionsToWeights, user.id());
|
||||
}
|
||||
|
||||
private void saveSelection(String pollId, Map<Integer, Integer> optionsToWeights, Object editor) {
|
||||
|
||||
var query = replaceInto(TABLE_SELECTIONS,POLL_ID,OPTION_ID,USER,WEIGHT);
|
||||
for (var entry : optionsToWeights.entrySet()){
|
||||
var optionId = entry.getKey();
|
||||
var weight = entry.getValue();
|
||||
query.values(pollId,optionId,editor,weight);
|
||||
}
|
||||
try {
|
||||
query.execute(db).close();
|
||||
} catch (SQLException sqle){
|
||||
throw UmbrellaException.failedToStoreObject(Text.SELECTIONS);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setPermission(String pollId, UmbrellaUser user, Permission permission) {
|
||||
try {
|
||||
if (permission == null){
|
||||
delete().from(TABLE_SHARES).where(POLL_ID,equal(pollId)).where(USER_ID,equal(user.id())).execute(db);
|
||||
} else replaceInto(TABLE_SHARES, POLL_ID, USER_ID, PERMISSION)
|
||||
.values(pollId, user.id(), permission.code()).execute(db).close();
|
||||
} catch (SQLException e) {
|
||||
throw failedToStoreObject(Text.PERMISSION);
|
||||
}
|
||||
}
|
||||
|
||||
private Poll update(Poll poll) {
|
||||
if (poll.isDirty(NAME, DESCRIPTION,PRIVATE)) try {
|
||||
replaceInto(TABLE_POLLS,ID,Field.USER_ID,NAME,DESCRIPTION,PRIVATE).values(poll.id(),poll.owner().id(),poll.name(),poll.description(),poll.isPrivate()).execute(db).close();
|
||||
} catch (SQLException e){
|
||||
throw failedToStoreObject(poll);
|
||||
}
|
||||
if (poll.isDirty(WEIGHTS)) try {
|
||||
Map.copyOf(poll.weights())
|
||||
.entrySet().stream()
|
||||
.filter(entry -> entry.getValue().isBlank())
|
||||
.map(Map.Entry::getKey)
|
||||
.forEach(w -> dropWeight(poll,w));
|
||||
var query = replaceInto(TABLE_WEIGHTS, POLL_ID, WEIGHT, DESCRIPTION);
|
||||
for (var entry : poll.weights().entrySet()){
|
||||
query.values(poll.id(),entry.getKey(),entry.getValue());
|
||||
}
|
||||
query.execute(db).close();
|
||||
} catch (SQLException e) {
|
||||
throw failedToStoreObject(poll);
|
||||
}
|
||||
for (var option : poll.options()) saveOption(poll.id(),option);
|
||||
return poll;
|
||||
}
|
||||
|
||||
private Poll.Option update(String pollId, Poll.Option option) throws SQLException {
|
||||
if (option.name().isBlank()){
|
||||
delete().from(TABLE_OPTIONS).where(POLL_ID,equal(pollId)).where(ID,equal(option.id())).execute(db);
|
||||
return null;
|
||||
}
|
||||
replaceInto(TABLE_OPTIONS,POLL_ID, ID, NAME, DESCRIPTION, STATUS)
|
||||
.values(pollId, option.id(), option.name(), option.description(), option.status()).execute(db).close();
|
||||
return option;
|
||||
}
|
||||
|
||||
private void updateSharesTable() {
|
||||
try {
|
||||
var newPermissions = new HashMap<String, Map<Long, Permission>>(); // Map from PollId → (Map from UserId → Permission)
|
||||
var rs = select(ALL).from(TABLE_SHARES).exec(db);
|
||||
while (rs.next()) {
|
||||
var pollID = rs.getString(POLL_ID);
|
||||
var userId = rs.getLong(USER_ID);
|
||||
var perm = rs.getInt(PERMISSION);
|
||||
var userMap = newPermissions.computeIfAbsent(pollID, k -> new HashMap<>());
|
||||
switch (perm) {
|
||||
case 0:
|
||||
userMap.put(userId, READ_ONLY);
|
||||
break;
|
||||
case 1:
|
||||
case 2:
|
||||
userMap.put(userId, Permission.EDIT);
|
||||
break;
|
||||
default:
|
||||
userMap.put(userId, null);
|
||||
}
|
||||
}
|
||||
rs.close();
|
||||
for (var pollEntry : newPermissions.entrySet()){
|
||||
var pollId = pollEntry.getKey();
|
||||
for (var userEntry : pollEntry.getValue().entrySet()){
|
||||
var userId = userEntry.getKey();
|
||||
var permission = userEntry.getValue();
|
||||
if (permission == null) {
|
||||
Query.delete().from(TABLE_SHARES)
|
||||
.where(USER_ID, equal(userId))
|
||||
.where(POLL_ID, equal(pollId))
|
||||
.execute(db);
|
||||
} else {
|
||||
Query.update(TABLE_SHARES)
|
||||
.where(USER_ID, equal(userId))
|
||||
.where(POLL_ID, equal(pollId))
|
||||
.set(PERMISSION).prepare(db)
|
||||
.apply(permission.code()).close();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (SQLException ex){
|
||||
throw databaseException("Failed to update permissions in shares table!");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import static de.srsoftware.umbrella.core.ConnectionProvider.connect;
|
||||
import static de.srsoftware.umbrella.core.ModuleRegistry.*;
|
||||
import static de.srsoftware.umbrella.core.Util.mapValues;
|
||||
import static de.srsoftware.umbrella.core.constants.Field.*;
|
||||
import static de.srsoftware.umbrella.core.constants.Field.PERMISSION;
|
||||
import static de.srsoftware.umbrella.core.constants.Field.SETTINGS;
|
||||
import static de.srsoftware.umbrella.core.constants.Field.TAGS;
|
||||
import static de.srsoftware.umbrella.core.constants.Module.PROJECT;
|
||||
|
||||
@@ -8,10 +8,12 @@ include("contact")
|
||||
include("core")
|
||||
include("documents")
|
||||
include("files")
|
||||
include("journal")
|
||||
include("legacy")
|
||||
include("messages")
|
||||
include("markdown")
|
||||
include("notes")
|
||||
include("poll")
|
||||
include("project")
|
||||
include("stock")
|
||||
include("tags")
|
||||
@@ -22,4 +24,3 @@ include("user")
|
||||
include("web")
|
||||
include("wiki")
|
||||
|
||||
include("journal")
|
||||
@@ -33,12 +33,10 @@ import de.srsoftware.umbrella.core.constants.Text;
|
||||
import de.srsoftware.umbrella.core.exceptions.UmbrellaException;
|
||||
import de.srsoftware.umbrella.core.model.*;
|
||||
import de.srsoftware.umbrella.core.model.Location;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.*;
|
||||
|
||||
import de.srsoftware.umbrella.messagebus.events.Event;
|
||||
import de.srsoftware.umbrella.messagebus.events.ItemEvent;
|
||||
import java.io.IOException;
|
||||
import java.util.*;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class StockModule extends BaseHandler implements StockService {
|
||||
@@ -120,6 +118,7 @@ public class StockModule extends BaseHandler implements StockService {
|
||||
yield super.doGet(path,ex);
|
||||
}
|
||||
}
|
||||
case Path.ITEM -> getItemById(user.get(),path,ex);
|
||||
case Path.LOCATION -> {
|
||||
try {
|
||||
var location = Location.of(Long.parseLong(path.pop()));
|
||||
@@ -162,6 +161,22 @@ public class StockModule extends BaseHandler implements StockService {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean getItemById(UmbrellaUser user, de.srsoftware.tools.Path path, HttpExchange ex) throws IOException {
|
||||
var head = path.pop();
|
||||
if (head == null) throw missingField(Field.ID);
|
||||
try {
|
||||
var itemId = Long.parseLong(head);
|
||||
var item = stockDb.loadItem(itemId);
|
||||
var owner = item.location().resolve().owner().resolve();
|
||||
boolean allowed = owner instanceof UmbrellaUser u && user.equals(u);
|
||||
allowed = allowed || owner instanceof Company c && companyService().membership(c.id(),user.id());
|
||||
if (!allowed) throw forbidden("You are not allowed to access item {id}",ID,itemId);
|
||||
return sendContent(ex,item);
|
||||
} catch (NumberFormatException e) {
|
||||
throw invalidField(Field.ID, Text.NUMBER);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean doPatch(de.srsoftware.tools.Path path, HttpExchange ex) throws IOException {
|
||||
addCors(ex);
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"allowed_states": "zulässige Status",
|
||||
"amount": "Menge",
|
||||
"archive": "archivieren",
|
||||
"average": "Durchschnitt",
|
||||
|
||||
"bank_account": "Bankverbindung",
|
||||
"base_url": "Basis-URL",
|
||||
@@ -116,6 +117,7 @@
|
||||
"end": "Ende",
|
||||
"estimated_time": "geschätzte Zeit",
|
||||
"estimated_times": "geschätzte Zeiten",
|
||||
"evaluate": "auswerten",
|
||||
"expand_on_click": "Anklicken zum Anzeigen",
|
||||
"extended_settings": "erweiterte Einstellungen",
|
||||
|
||||
@@ -169,6 +171,7 @@
|
||||
"hide": "ausblenden",
|
||||
"hide_closed": "geschlossene ausblenden",
|
||||
"hide_on_index_page": "nicht in der Aufgabenübersicht anzeigen",
|
||||
"histogram": "Histogramm",
|
||||
"hours": "Stunden",
|
||||
|
||||
"id": "Id",
|
||||
@@ -196,6 +199,7 @@
|
||||
"locality": "Ort",
|
||||
"location": "Ort",
|
||||
"locations": "Orte",
|
||||
"logged in as: {user}": "Anegemeldet als: {user}",
|
||||
"login" : "Anmeldung",
|
||||
"login service": "Login-Service",
|
||||
"login_services": "Login-Services",
|
||||
@@ -262,8 +266,11 @@
|
||||
|
||||
"oidc_Login" : "Anmeldung mit OIDC",
|
||||
"old_password": "altes Passwort",
|
||||
"option": "Option",
|
||||
"options": "Optionen",
|
||||
"organization": "Organisation",
|
||||
"other_tags": "andere Tags",
|
||||
"owner": "Eigentümer",
|
||||
|
||||
"page": "Seite",
|
||||
"parent_task": "übergeordnete Aufgabe",
|
||||
@@ -280,6 +287,8 @@
|
||||
"permission_read_only": "lesen",
|
||||
"phone": "Telefon",
|
||||
"pieces": "Stück",
|
||||
"poll": "Umfrage",
|
||||
"polls": "Umfragen",
|
||||
"pos": "Pos",
|
||||
"position": "Position",
|
||||
"positions": "Positionen",
|
||||
@@ -417,8 +426,11 @@
|
||||
"version": "Version",
|
||||
"version_of": "Version {version} von {element}",
|
||||
"visible_to_guests": "Für Besucher sichtbar",
|
||||
"voted {count} times": "{count} mal gewählt",
|
||||
|
||||
"website": "Website",
|
||||
"weight": "Gewichtung",
|
||||
"weights": "Gewichtungen",
|
||||
"welcome" : "Willkommen, {0}",
|
||||
"When shall messages be delivered?": "Wann sollen Nachrichten zugestellt werden?",
|
||||
"wiki": "Wiki",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"allowed_states": "allowed states",
|
||||
"amount": "amount",
|
||||
"archive": "archive",
|
||||
"average": "average",
|
||||
|
||||
"bank_account": "bank account",
|
||||
"base_url": "base URL",
|
||||
@@ -116,6 +117,7 @@
|
||||
"end": "end",
|
||||
"estimated_time": "estimated duration",
|
||||
"estimated_times": "estimated durations",
|
||||
"evaluate": "evaluate",
|
||||
"expand_on_click": "click to expand",
|
||||
"extended_settings": "extended settings",
|
||||
|
||||
@@ -169,6 +171,7 @@
|
||||
"hide": "hide",
|
||||
"hide_closed": "hide closed",
|
||||
"hide_on_index_page": "hide on index page",
|
||||
"histogram": "histogram",
|
||||
"hours": "hours",
|
||||
|
||||
"id": "ID",
|
||||
@@ -196,6 +199,7 @@
|
||||
"locality": "locality",
|
||||
"location": "location",
|
||||
"locations": "locations",
|
||||
"logged in as: {user}": "logged in as: {user}",
|
||||
"login" : "login",
|
||||
"login service": "login service",
|
||||
"login_services": "login service",
|
||||
@@ -262,8 +266,11 @@
|
||||
|
||||
"oidc_Login" : "Login via OIDC",
|
||||
"old_password": "old password",
|
||||
"option": "option",
|
||||
"options": "options",
|
||||
"organization": "organization",
|
||||
"other_tags": "other tags",
|
||||
"owner": "owner",
|
||||
|
||||
"page": "page",
|
||||
"parent_task": "parent task",
|
||||
@@ -280,6 +287,8 @@
|
||||
"permission_read_only": "read-only",
|
||||
"phone": "phone",
|
||||
"pieces": "pieces",
|
||||
"poll": "poll",
|
||||
"polls": "polls",
|
||||
"pos": "pos",
|
||||
"position": "position",
|
||||
"positions": "positions",
|
||||
@@ -417,8 +426,11 @@
|
||||
"version": "version",
|
||||
"version_of": "version {version} of {element}",
|
||||
"visible_to_guests": "visible to guests",
|
||||
"voted {count} times": "voted {count} times",
|
||||
|
||||
"website": "website",
|
||||
"weight": "weight",
|
||||
"weights": "weights",
|
||||
"welcome" : "Welcome, {0}",
|
||||
"When shall messages be delivered?": "When shall messages be delivered?",
|
||||
"wiki": "Wiki",
|
||||
|
||||
@@ -313,6 +313,10 @@ tr:hover .taglist .tag button {
|
||||
background: black;
|
||||
}
|
||||
|
||||
.histo .bar{
|
||||
border: 1px solid red;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 900px) {
|
||||
#app nav a{
|
||||
background: black;
|
||||
|
||||
@@ -504,6 +504,14 @@ a.wikilink{
|
||||
}
|
||||
}
|
||||
|
||||
.poll .weight .description{
|
||||
display: block;
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.histo {
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.grid2{
|
||||
|
||||
@@ -304,6 +304,10 @@ tr:hover .taglist .tag button {
|
||||
background: black;
|
||||
}
|
||||
|
||||
.histo .bar{
|
||||
background: #a00;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 900px) {
|
||||
#app nav a{
|
||||
background: black;
|
||||
|
||||
@@ -109,6 +109,10 @@ nav a.note::before {
|
||||
content: " ";
|
||||
}
|
||||
|
||||
nav a.poll::before {
|
||||
content: " ";
|
||||
}
|
||||
|
||||
nav a.project::before {
|
||||
content: " ";
|
||||
}
|
||||
@@ -140,6 +144,7 @@ nav a.file,
|
||||
nav a.mark,
|
||||
nav a.message,
|
||||
nav a.note,
|
||||
nav a.poll,
|
||||
nav a.project,
|
||||
nav a.stock,
|
||||
nav a.tags,
|
||||
@@ -619,6 +624,14 @@ a.wikilink{
|
||||
}
|
||||
}
|
||||
|
||||
.poll .weight .description{
|
||||
display: block;
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.histo {
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.grid2,
|
||||
|
||||
@@ -283,6 +283,10 @@ tr:hover .taglist .tag button {
|
||||
color: blue;
|
||||
}
|
||||
|
||||
.histo .bar{
|
||||
background: cyan;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 900px) {
|
||||
#app nav a{
|
||||
background: white;
|
||||
|
||||
@@ -504,6 +504,14 @@ a.wikilink{
|
||||
}
|
||||
}
|
||||
|
||||
.poll .weight .description{
|
||||
display: block;
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.histo {
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.grid2{
|
||||
|
||||
Reference in New Issue
Block a user